Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5f5292ed | ||
|
|
e326e26389 | ||
|
|
275d64e379 | ||
|
|
f7f74fee8c | ||
|
|
643a9f1795 | ||
|
|
709b864c60 | ||
|
|
03834c6855 | ||
|
|
0ee7cd1274 | ||
|
|
1bc3940ee8 | ||
|
|
164db495b4 | ||
|
|
cff48f7cc7 | ||
|
|
bb5f2a1121 | ||
|
|
63a2cddd5e | ||
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f |
+14
-5
@@ -1,13 +1,23 @@
|
|||||||
|
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
||||||
|
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
|
**/*.egg-info/
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
backend/*.db
|
|
||||||
backend/*.db-wal
|
|
||||||
backend/*.db-shm
|
|
||||||
backend/openapi.json
|
backend/openapi.json
|
||||||
|
|
||||||
|
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
||||||
|
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
||||||
|
backend/data/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
**/*.db
|
||||||
|
**/*.db-wal
|
||||||
|
**/*.db-shm
|
||||||
|
|
||||||
# Node / сборка фронта
|
# Node / сборка фронта
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
@@ -23,8 +33,7 @@ backend/tests/
|
|||||||
backend/pyproject.toml
|
backend/pyproject.toml
|
||||||
|
|
||||||
# Прочее
|
# Прочее
|
||||||
.env
|
**/.env
|
||||||
data/
|
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
*.md
|
*.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ APP_ENV=development
|
|||||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||||
|
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
||||||
|
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
||||||
|
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
||||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||||
LOCAL_PUBLIC=local
|
LOCAL_PUBLIC=local
|
||||||
@@ -54,6 +57,7 @@ PUBLIC_BASE_URL=
|
|||||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||||
|
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
||||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_USER_TTL_MINUTES=10080
|
JWT_USER_TTL_MINUTES=10080
|
||||||
|
|||||||
@@ -125,8 +125,10 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
||||||
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
||||||
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
||||||
дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только
|
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
|
||||||
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
|
при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и
|
||||||
|
`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские
|
||||||
|
сессии завершатся; подробно — в `deploy/pi/README.md`).
|
||||||
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||||
|
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
@@ -200,9 +202,8 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
||||||
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
||||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
||||||
исключены из образа (`.dockerignore`).
|
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
|
||||||
- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило
|
и локальные бэкапы (`backups/`) в образ тоже не попадают.
|
||||||
`data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71).
|
|
||||||
|
|
||||||
## Git и деплой
|
## Git и деплой
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||||
|
|
||||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||||
|
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password
|
|||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.seed.reference_data import seed_reference_data
|
from app.seed.reference_data import seed_reference_data
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
|
||||||
def _ensure_admin(session: Session) -> None:
|
def _ensure_admin(session: Session) -> None:
|
||||||
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
|
|
||||||
# Администратор уже существует.
|
# Администратор уже существует.
|
||||||
if not settings.is_development:
|
if not settings.is_development:
|
||||||
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
||||||
|
# по себе ничего не делает — применить её можно только осознанно, командой
|
||||||
|
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
||||||
return
|
return
|
||||||
|
|
||||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||||
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_admin_password(session: Session) -> User:
|
||||||
|
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
|
||||||
|
|
||||||
|
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
|
||||||
|
это единственный штатный способ, и он требует доступа к серверу. set_password
|
||||||
|
проверяет пароль и увеличивает token_version — все выданные админские сессии
|
||||||
|
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
|
||||||
|
if not settings.admin_bootstrap_enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
|
||||||
|
)
|
||||||
|
password = (settings.admin_password or "").strip()
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
|
||||||
|
admin = session.exec(select(User).where(User.role == "admin")).first()
|
||||||
|
if admin is None:
|
||||||
|
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
|
||||||
|
return user_service.set_password(session, admin, password)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> None:
|
def bootstrap() -> None:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
seed_reference_data(session) # идемпотентно
|
seed_reference_data(session) # идемпотентно
|
||||||
_ensure_admin(session)
|
_ensure_admin(session)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__)
|
||||||
|
ap.add_argument(
|
||||||
|
"--reset-admin-password",
|
||||||
|
action="store_true",
|
||||||
|
help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии",
|
||||||
|
)
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
if not args.reset_admin_password:
|
||||||
|
bootstrap()
|
||||||
|
return
|
||||||
|
with Session(engine) as session:
|
||||||
|
admin = reset_admin_password(session)
|
||||||
|
print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; "
|
||||||
|
"все его сессии завершены.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
bootstrap()
|
main()
|
||||||
|
|||||||
+26
-13
@@ -12,8 +12,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
|
||||||
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
|
||||||
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||||
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
_MIN_SECRET_KEY_LENGTH = 32
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
@@ -38,7 +38,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||||
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
|
||||||
|
# от этого зависят Secure-cookie и проверка секретов при старте.
|
||||||
local_public: str = "local"
|
local_public: str = "local"
|
||||||
|
|
||||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||||
@@ -111,11 +112,18 @@ class Settings(BaseSettings):
|
|||||||
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def is_published(self) -> bool:
|
||||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
"""Приложение доступно снаружи по домену: production или dev, выставленный через
|
||||||
нативный dev на localhost по HTTP (development + local_public=local)."""
|
VPS-туннель. Не опубликован только нативный dev на localhost
|
||||||
|
(development + local_public=local)."""
|
||||||
return not (self.is_development and self.local_public.lower() == "local")
|
return not (self.is_development and self.local_public.lower() == "local")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookie_secure(self) -> bool:
|
||||||
|
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
|
||||||
|
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
|
||||||
|
return self.is_published
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
@@ -132,13 +140,13 @@ class Settings(BaseSettings):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
def _forbid_default_secrets_when_published(self) -> "Settings":
|
||||||
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
||||||
|
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
|
||||||
|
|
||||||
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
|
||||||
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
|
||||||
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
|
if not self.is_published:
|
||||||
if self.app_env.lower() != "production":
|
|
||||||
return self
|
return self
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||||
@@ -150,8 +158,13 @@ class Settings(BaseSettings):
|
|||||||
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||||
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||||
if problems:
|
if problems:
|
||||||
|
where = (
|
||||||
|
"production"
|
||||||
|
if self.is_production
|
||||||
|
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
|
||||||
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Небезопасная конфигурация production — задайте секреты в .env: "
|
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
|
||||||
+ "; ".join(problems)
|
+ "; ".join(problems)
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
hub.bind_loop(asyncio.get_running_loop())
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
|
||||||
|
if settings.is_development and settings.is_published:
|
||||||
|
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
||||||
|
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
||||||
|
logging.getLogger("fs").warning(
|
||||||
|
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
||||||
|
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
||||||
|
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
||||||
|
settings.local_public,
|
||||||
|
)
|
||||||
|
|
||||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||||
|
|||||||
@@ -315,16 +315,15 @@ def delete_match(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.OkResponse:
|
) -> s.OkResponse:
|
||||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
match = match_service.get_match(session, match_id)
|
||||||
# До удаления: каскад унесёт участников вместе с партией.
|
group_id, finished = match.group_id, match.status == "finished" # для уведомления
|
||||||
participant_ids = notify.match_participant_ids(session, match_id)
|
|
||||||
admin_service.delete_match(session, match_id)
|
admin_service.delete_match(session, match_id)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id, group_id, participant_ids)
|
notify.match_removed(session, match_id, group_id, finished=finished)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -352,9 +352,7 @@ def delete_match(
|
|||||||
match_service.assert_can_modify(session, match, user)
|
match_service.assert_can_modify(session, match, user)
|
||||||
match_id_val = match.id
|
match_id_val = match.id
|
||||||
group_id_val = match.group_id
|
group_id_val = match.group_id
|
||||||
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
finished = match.status == "finished" # после удаления статус уже не прочитать
|
||||||
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
|
||||||
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
|
||||||
match_service.delete_match(session, match, expected_version=expected_version)
|
match_service.delete_match(session, match, expected_version=expected_version)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
@@ -367,5 +365,5 @@ def delete_match(
|
|||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
|
|||||||
|
|
||||||
|
|
||||||
def seed_reference_data(session: Session) -> None:
|
def seed_reference_data(session: Session) -> None:
|
||||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
|
||||||
|
|
||||||
|
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
|
||||||
|
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
|
||||||
|
и после создания записи источник правды для него — БД (#72). Имя из кода получает
|
||||||
|
только новая фракция; поправить имя существующей — админкой или миграцией.
|
||||||
|
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
|
||||||
code_to_expansion: dict[str, Expansion] = {}
|
code_to_expansion: dict[str, Expansion] = {}
|
||||||
|
|
||||||
for code, name_ru, is_base, order in EXPANSIONS:
|
for code, name_ru, is_base, order in EXPANSIONS:
|
||||||
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
|
|||||||
)
|
)
|
||||||
session.add(fac)
|
session.add(fac)
|
||||||
else:
|
else:
|
||||||
fac.name_ru = name_ru
|
|
||||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||||
fac.sort_order = order
|
fac.sort_order = order
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
|
|
||||||
|
|
||||||
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
||||||
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
|
||||||
он задаётся ADMIN_PASSWORD в .env."""
|
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
|
||||||
|
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
|
||||||
user = session.get(User, user_id)
|
user = session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("Пользователь не найден.")
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.events import hub
|
from app.core.events import hub
|
||||||
from app.models import GroupMember, Match, MatchParticipant, User
|
from app.models import GroupMember, Match, User
|
||||||
|
|
||||||
|
|
||||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||||
@@ -17,30 +17,28 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
||||||
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
||||||
(история игр, публичный профиль, личная статистика) реально протухли."""
|
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
||||||
return list(
|
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
||||||
session.exec(
|
skip = set(notified)
|
||||||
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
|
ids = [
|
||||||
|
uid
|
||||||
|
for uid in session.exec(
|
||||||
|
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||||
).all()
|
).all()
|
||||||
)
|
if uid not in skip
|
||||||
|
]
|
||||||
|
hub.publish(ids, {"type": "ratings"})
|
||||||
|
|
||||||
|
|
||||||
def match_changed(session: Session, match: Match) -> None:
|
def match_changed(session: Session, match: Match) -> None:
|
||||||
"""Партия изменилась — уведомить всех участников её группы.
|
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
||||||
|
и остальных игроков (_ratings_changed)."""
|
||||||
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
members = _group_member_ids(session, match.group_id)
|
||||||
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
||||||
hub.publish(
|
if match.status == "finished":
|
||||||
_group_member_ids(session, match.group_id),
|
_ratings_changed(session, members)
|
||||||
{
|
|
||||||
"type": "match",
|
|
||||||
"match_id": match.id,
|
|
||||||
"group_id": match.group_id,
|
|
||||||
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||||
@@ -53,22 +51,13 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
|||||||
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
||||||
|
|
||||||
|
|
||||||
def match_removed(
|
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
|
||||||
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
|
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
||||||
) -> None:
|
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
||||||
"""Партия удалена — уведомить участников группы (обновить списки).
|
members = _group_member_ids(session, group_id)
|
||||||
|
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
||||||
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
if finished:
|
||||||
ушли каскадом, и собрать их из базы невозможно."""
|
_ratings_changed(session, members)
|
||||||
hub.publish(
|
|
||||||
_group_member_ids(session, group_id),
|
|
||||||
{
|
|
||||||
"type": "match",
|
|
||||||
"match_id": match_id,
|
|
||||||
"group_id": group_id,
|
|
||||||
"participant_ids": participant_ids or [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
||||||
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
||||||
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
||||||
K_i спускается от K_MAX у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола,
|
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
|
||||||
M — множитель отрыва (темп, цели, миры; близость по типу победы — только у пар
|
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
|
||||||
с победителем). Недостающий признак партии подставляется типичным и не влияет на M.
|
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
|
||||||
|
признак партии подставляется типичным и не влияет на M.
|
||||||
|
|
||||||
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
||||||
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
||||||
@@ -168,6 +169,10 @@ def rate_match(
|
|||||||
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
||||||
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
|
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
|
||||||
for a, b in combinations(m.seats, 2):
|
for a, b in combinations(m.seats, 2):
|
||||||
|
if a.eliminated and b.eliminated:
|
||||||
|
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
|
||||||
|
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
|
||||||
|
continue
|
||||||
if a.place > b.place:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
|
from app.models import Faction
|
||||||
|
from app.seed.reference_data import FACTIONS, seed_reference_data
|
||||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||||
|
|
||||||
|
|
||||||
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
|||||||
assert ap["faction_name"] == "Орки WAAAGH"
|
assert ap["faction_name"] == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
|
||||||
|
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
|
||||||
|
имя, заданное админом (#72)."""
|
||||||
|
login(client, "Кто-то")
|
||||||
|
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/factions/{orks['id']}",
|
||||||
|
json={"name_ru": "Орки WAAAGH"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
seed_reference_data(s) # то же, что делает рестарт
|
||||||
|
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeding_is_complete_and_idempotent():
|
||||||
|
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with Session(engine) as s:
|
||||||
|
seed_reference_data(s)
|
||||||
|
seed_reference_data(s)
|
||||||
|
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
|
||||||
|
assert names == {code: name for code, name, _exp, _order in FACTIONS}
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||||
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app import bootstrap
|
||||||
|
from app.core.security import verify_password
|
||||||
|
from app.models import User
|
||||||
|
from tests.conftest import csrf_headers
|
||||||
|
|
||||||
|
OLD, NEW = "secret123", "brand-new-admin-pw"
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(engine) -> User:
|
||||||
|
with Session(engine) as s:
|
||||||
|
return s.exec(select(User).where(User.role == "admin")).one()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient, password: str):
|
||||||
|
return client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": password},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_applies_env_password_and_revokes_sessions(
|
||||||
|
client: TestClient, engine, make_admin, monkeypatch
|
||||||
|
):
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
assert _login(client, OLD).status_code == 200
|
||||||
|
assert client.get("/api/admin/me").status_code == 200
|
||||||
|
version = _admin(engine).token_version
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s:
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
|
|
||||||
|
admin = _admin(engine)
|
||||||
|
assert verify_password(NEW, admin.password_hash)
|
||||||
|
assert admin.token_version == version + 1
|
||||||
|
# Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль).
|
||||||
|
assert client.get("/api/admin/me").status_code == 401
|
||||||
|
assert _login(client, OLD).status_code == 401
|
||||||
|
assert _login(client, NEW).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch):
|
||||||
|
"""Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация."""
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "app_env", "production")
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s:
|
||||||
|
bootstrap._ensure_admin(s)
|
||||||
|
assert verify_password(OLD, _admin(engine).password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch):
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"):
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
|
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", " ")
|
||||||
|
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"):
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
|
assert verify_password(OLD, _admin(engine).password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch):
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"):
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
|
||||||
|
стартует с дефолтными секретами (#59, F4, #69)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@@ -61,10 +64,70 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
|
|||||||
def test_development_allows_defaults():
|
def test_development_allows_defaults():
|
||||||
s = config.Settings(
|
s = config.Settings(
|
||||||
app_env="development",
|
app_env="development",
|
||||||
|
local_public="local",
|
||||||
secret_key=config._DEFAULT_SECRET_KEY,
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
)
|
)
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
assert not s.is_published and not s.cookie_secure
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dev, опубликованный на домен (LOCAL_PUBLIC=vps, #69) ─────────────────────
|
||||||
|
# Снаружи он так же доступен, как прод: общеизвестный ключ JWT и пароль админа там
|
||||||
|
# открывают админку и подделку любого токена.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"secret_key,admin_password",
|
||||||
|
[
|
||||||
|
(config._DEFAULT_SECRET_KEY, _STRONG_ADMIN_PW),
|
||||||
|
("too-short", _STRONG_ADMIN_PW),
|
||||||
|
(_STRONG_SECRET, config._DEFAULT_ADMIN_PASSWORD),
|
||||||
|
],
|
||||||
|
ids=["default-secret", "short-secret", "default-admin-password"],
|
||||||
|
)
|
||||||
|
def test_published_dev_rejects_weak_secrets(secret_key, admin_password):
|
||||||
|
with pytest.raises(ValidationError, match="LOCAL_PUBLIC=vps"):
|
||||||
|
config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
local_public="vps",
|
||||||
|
secret_key=secret_key,
|
||||||
|
admin_password=admin_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_dev_accepts_strong_secrets():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
local_public="vps",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
assert s.is_development and s.is_published and s.cookie_secure
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_dev_warns_on_startup(monkeypatch, caplog):
|
||||||
|
"""Dev-инструменты на опубликованном dev остаются (решение владельца) — но старт
|
||||||
|
громко перечисляет, что открыто любому посетителю домена."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.settings, "local_public", "vps")
|
||||||
|
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
|
||||||
|
pass
|
||||||
|
assert "DEV ОПУБЛИКОВАН НАРУЖУ" in caplog.text
|
||||||
|
assert "вход по нику без пароля" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_dev_starts_quietly(caplog):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
|
||||||
|
pass
|
||||||
|
assert "DEV ОПУБЛИКОВАН НАРУЖУ" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
|
||||||
|
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
|||||||
return published
|
return published
|
||||||
|
|
||||||
|
|
||||||
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
|
||||||
return [e for _ids, e in published if e.get("type") == "match"]
|
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
|
||||||
|
|
||||||
|
|
||||||
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
|
||||||
|
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
|
||||||
me = login(client, "Хост")
|
me = login(client, "Хост")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
|
||||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
def group(name: str) -> int:
|
||||||
).json()["id"]
|
return client.post(
|
||||||
|
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
gid, other_gid = group("Группа"), group("Другая")
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
outsider = add_group_member(engine, other_gid, "Чужой")
|
||||||
p3 = add_group_member(engine, gid, "Зритель")
|
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return me, gid, p2, outsider, fids
|
||||||
|
|
||||||
|
|
||||||
|
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
||||||
|
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
started = start_match(
|
started = start_match(
|
||||||
@@ -37,36 +47,31 @@ def test_match_event_carries_participants(client: TestClient, engine, monkeypatc
|
|||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
)
|
)
|
||||||
assert started.status_code == 200, started.text
|
assert started.status_code == 200, started.text
|
||||||
mid = started.json()["id"]
|
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
|
||||||
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
ev = _match_events(published)[-1]
|
assert _recipients(published, "ratings") == set()
|
||||||
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
|
||||||
assert p3 not in ev["participant_ids"]
|
|
||||||
|
|
||||||
published.clear()
|
published.clear()
|
||||||
fin = finish_match(
|
fin = finish_match(
|
||||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||||
)
|
)
|
||||||
assert fin.status_code == 200, fin.text
|
assert fin.status_code == 200, fin.text
|
||||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
|
ratings = _recipients(published, "ratings")
|
||||||
|
assert outsider in ratings
|
||||||
|
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
|
||||||
|
|
||||||
|
|
||||||
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
def test_deleting_finished_match_reaches_players_outside_group(
|
||||||
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
client: TestClient, engine, monkeypatch
|
||||||
|
):
|
||||||
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
|
||||||
обновит историю тем, кто в этой партии играл."""
|
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||||
me = login(client, "Хост")
|
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
|
||||||
gid = client.post(
|
|
||||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
|
||||||
).json()["id"]
|
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
|
||||||
mid = start_match(
|
mid = start_match(
|
||||||
client, gid,
|
client, gid,
|
||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
|
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
version = client.get(f"/api/matches/{mid}").json()["version"]
|
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
@@ -74,4 +79,5 @@ def test_delete_event_carries_participants(client: TestClient, engine, monkeypat
|
|||||||
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
|
assert outsider in _recipients(published, "ratings")
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ EXAMPLES = [
|
|||||||
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
||||||
"objectives", end_round=7,
|
"objectives", end_round=7,
|
||||||
),
|
),
|
||||||
{A: 9.27, B: 5.85, C: -7.78, D: -7.35}),
|
{A: 9.27, B: 5.85, C: -7.6, D: -7.53}),
|
||||||
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
||||||
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
||||||
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
|
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
|
||||||
@@ -101,6 +101,23 @@ def test_last_standing_counts_full_objective_gap():
|
|||||||
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
|
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eliminated_are_not_compared_with_each_other():
|
||||||
|
"""Выбывшие между собой не сравниваются (#91): слабый выбывший среди сильных не
|
||||||
|
получает рейтинг, а рейтинги прочих выбывших на его изменение не влияют."""
|
||||||
|
six = range(A, F + 1)
|
||||||
|
seats = (_seat(A, 1),) + tuple(_seat(u, 2, eliminated=True) for u in six if u != A)
|
||||||
|
match = RatedMatch(seats, "last_standing")
|
||||||
|
strong = {**dict.fromkeys(six, 1800), F: 1200}
|
||||||
|
delta = rate_match(strong, _vets(*six), match)[0]
|
||||||
|
assert delta[F] < 0
|
||||||
|
# Сильные выбывшие → слабые: у F и у победителя ничего не меняется от этого.
|
||||||
|
weak = {**dict.fromkeys(six, 1200), A: 1800}
|
||||||
|
again = rate_match(weak, _vets(*six), match)[0]
|
||||||
|
assert again[F] == pytest.approx(delta[F])
|
||||||
|
# Победителю сила соперников по-прежнему важна: против слабых он получает меньше.
|
||||||
|
assert again[A] < delta[A]
|
||||||
|
|
||||||
|
|
||||||
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
|
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
|
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
|
||||||
@@ -147,6 +164,7 @@ def test_constants_match_reference(sim):
|
|||||||
assert dict(p.closeness) == scoring.CLOSENESS
|
assert dict(p.closeness) == scoring.CLOSENESS
|
||||||
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
||||||
assert not p.autocorr
|
assert not p.autocorr
|
||||||
|
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
||||||
@@ -174,8 +192,8 @@ def test_replay_matches_reference_season(sim, scenario, stripped):
|
|||||||
|
|
||||||
|
|
||||||
def test_monotone_and_zero_sum(sim):
|
def test_monotone_and_zero_sum(sim):
|
||||||
"""Победитель без ничьей не теряет, последний без ничьей не получает; при равных K
|
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
|
||||||
сумма изменений за партию — ноль."""
|
при равных K сумма изменений за партию — ноль."""
|
||||||
cfg = sim.SCENARIOS["сигнал"]
|
cfg = sim.SCENARIOS["сигнал"]
|
||||||
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
||||||
ids: dict[str, int] = {}
|
ids: dict[str, int] = {}
|
||||||
@@ -188,6 +206,8 @@ def test_monotone_and_zero_sum(sim):
|
|||||||
delta, _ = rate_match({}, veterans, rm)
|
delta, _ = rate_match({}, veterans, rm)
|
||||||
places = [s.place for s in rm.seats]
|
places = [s.place for s in rm.seats]
|
||||||
for s in rm.seats:
|
for s in rm.seats:
|
||||||
|
if s.eliminated:
|
||||||
|
assert delta[s.user_id] < 0
|
||||||
if places.count(s.place) > 1:
|
if places.count(s.place) > 1:
|
||||||
continue
|
continue
|
||||||
if s.place == 1:
|
if s.place == 1:
|
||||||
|
|||||||
+12
-3
@@ -92,9 +92,18 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
||||||
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
||||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если
|
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
|
||||||
> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены
|
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
|
||||||
> пароля админа на проде пока нет (задача #73).
|
>
|
||||||
|
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
|
||||||
|
> его не сменить:
|
||||||
|
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
|
||||||
|
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
|
||||||
|
> только при создании; без этого шага команда ниже увидит старый пароль);
|
||||||
|
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
|
||||||
|
>
|
||||||
|
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
|
||||||
|
> Логин (`ADMIN_USERNAME`) команда не меняет.
|
||||||
|
|
||||||
## 4. Запуск
|
## 4. Запуск
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
влияет, но слабее темпа.
|
влияет, но слабее темпа.
|
||||||
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
||||||
задачи «60 против 40» в ней — 1600 против 1400.
|
задачи «60 против 40» в ней — 1600 против 1400.
|
||||||
- **Монотонность.** Победитель никогда не теряет рейтинг, последнее место никогда не
|
- **Выбывшие между собой не сравниваются.** Все они проиграли, а миров у них нет: пара двух
|
||||||
приносит.
|
выбывших в расчёт не входит. Сила соперников при этом важна, как и прежде (4.4).
|
||||||
|
- **Монотонность.** Единоличный победитель и выбывшие всегда движутся в свою сторону: первый
|
||||||
|
не теряет рейтинг, вторые его не получают. Внутри ничьей невыбывших — за 1-е или
|
||||||
|
последнее место — пара может сдвинуть рейтинг в любую сторону (4.9).
|
||||||
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
||||||
признаки берутся нейтральными.
|
признаки берутся нейтральными.
|
||||||
|
|
||||||
@@ -159,7 +162,8 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
### 4.3. Фактический результат пары
|
### 4.3. Фактический результат пары
|
||||||
|
|
||||||
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
||||||
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`).
|
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
|
||||||
|
но между собой не сравниваются (4.4).
|
||||||
|
|
||||||
### 4.4. Изменение рейтинга
|
### 4.4. Изменение рейтинга
|
||||||
|
|
||||||
@@ -170,6 +174,14 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
||||||
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
||||||
|
|
||||||
|
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
|
||||||
|
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
|
||||||
|
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
|
||||||
|
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
|
||||||
|
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
|
||||||
|
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
|
||||||
|
получал рейтинг за счёт сильных выбывших.
|
||||||
|
|
||||||
### 4.5. Коэффициент K — скорость изменения
|
### 4.5. Коэффициент K — скорость изменения
|
||||||
|
|
||||||
```math
|
```math
|
||||||
@@ -236,7 +248,10 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 4.9. Свойства
|
### 4.9. Свойства
|
||||||
|
|
||||||
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
||||||
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг.
|
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
|
||||||
|
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
|
||||||
|
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
|
||||||
|
1-е место со слабым, может потерять.
|
||||||
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
||||||
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
||||||
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
||||||
@@ -352,16 +367,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 |
|
| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 |
|
||||||
| B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 |
|
| B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 |
|
||||||
| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 |
|
| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 |
|
||||||
| C–D | 0.5 | 0.543 | 1/4 (по модулю) | 0 | 1 − 0.125 − 0.25 = **0.625** | −0.027 |
|
| C–D | — | — | — | — | оба выбыли — пара не считается (4.4) | 0 |
|
||||||
|
|
||||||
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
||||||
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
||||||
- `ΔR_C = 6.67 · (−0.551 − 0.589 − 0.027)` = **−7.78**
|
- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
|
||||||
- `ΔR_D = 6.67 · (−0.540 − 0.589 + 0.027)` = **−7.35**
|
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
|
||||||
|
|
||||||
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
||||||
от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по
|
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
|
||||||
рейтингу, поэтому немного уступает D.
|
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
|
||||||
|
|
||||||
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
||||||
|
|
||||||
@@ -435,7 +450,9 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 7.3. Результаты: 200 сезонов на сценарий
|
### 7.3. Результаты: 200 сезонов на сценарий
|
||||||
|
|
||||||
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
||||||
так будет считаться история, накопленная до #23.
|
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
|
||||||
|
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
|
||||||
|
в третьем знаке, у остальных систем цифры прежние.
|
||||||
|
|
||||||
**Сигнал** (потолок точности 0.6843)
|
**Сигнал** (потолок точности 0.6843)
|
||||||
|
|
||||||
@@ -443,8 +460,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
|
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
|
||||||
| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 |
|
| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 |
|
||||||
| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.711** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
|
| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.712** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
|
||||||
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.782 | 0.864 | 47.4 | 0.79 | 5.90 |
|
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 |
|
||||||
|
|
||||||
**Клубы** (потолок 0.6308)
|
**Клубы** (потолок 0.6308)
|
||||||
|
|
||||||
@@ -452,8 +469,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
|
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
|
||||||
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
|
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
|
||||||
| **Предложенная** | **0.6102** | 0.2336 | **0.644** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
||||||
| Предложенная, без новых полей | 0.6074 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.534 | 103.3 | 0.38 | 6.41 |
|
| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
|
||||||
|
|
||||||
**Рост** (потолок 0.6864)
|
**Рост** (потолок 0.6864)
|
||||||
|
|
||||||
@@ -461,8 +478,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
|
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
|
||||||
| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 |
|
| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 |
|
||||||
| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.774** | **0.851** | **42.7** | **0.95** | 5.96 |
|
| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.775** | **0.850** | **42.7** | **0.95** | 5.96 |
|
||||||
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.6 | 0.79 | 5.91 |
|
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 |
|
||||||
|
|
||||||
**Шум** (потолок 0.6894)
|
**Шум** (потолок 0.6894)
|
||||||
|
|
||||||
@@ -470,16 +487,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
|
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
|
||||||
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
|
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
|
||||||
| Предложенная | 0.6727 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.6 | 0.81 | 5.91 |
|
| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
|
||||||
| Предложенная, без новых полей | **0.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 47.4 | 0.78 | 5.88 |
|
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
|
||||||
|
|
||||||
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
||||||
|
|
||||||
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.37 ± 0.07 п.п. |
|
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
|
||||||
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.57 ± 0.10 п.п. |
|
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
|
||||||
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.07 п.п. | +0.37 ± 0.06 п.п. |
|
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
|
||||||
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
||||||
|
|
||||||
Выводы:
|
Выводы:
|
||||||
@@ -490,7 +507,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
||||||
отрываются от середняков.
|
отрываются от середняков.
|
||||||
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
||||||
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.644): чемпион слабой группы
|
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы
|
||||||
у неё стоит рядом с чемпионом сильной.
|
у неё стоит рядом с чемпионом сильной.
|
||||||
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
||||||
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
||||||
@@ -506,6 +523,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|
|
||||||
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
||||||
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
||||||
|
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
|
||||||
|
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
|
||||||
|
|
||||||
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
||||||
Выгоден высокий K новичка и низкий K ветерана.
|
Выгоден высокий K новичка и низкий K ветерана.
|
||||||
@@ -732,6 +751,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
||||||
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
||||||
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
||||||
|
| 8 | Сравнивать ли выбывших между собой (после ревью, #87 → #91, 2026-09-18) | **Нет.** Все они проиграли и одинаково слабы в этой партии, миров у них нет; при расчёте победителя сила соперников по-прежнему важна | 4.3, 4.4, 4.9, пример 5, итоги 7.3 (подбор 7.4 не переигрывался) |
|
||||||
|
|
||||||
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
|
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
|
||||||
после внедрения #23 (раздел 8, «Калибровка»).
|
после внедрения #23 (раздел 8, «Калибровка»).
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class Params:
|
|||||||
m_min: float = 0.5
|
m_min: float = 0.5
|
||||||
m_max: float = 2.0
|
m_max: float = 2.0
|
||||||
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
||||||
|
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
|
||||||
|
# (решение владельца, #91). У систем для сравнения — как было.
|
||||||
|
skip_eliminated_pairs: bool = False
|
||||||
|
|
||||||
def closeness_for(self, reason: str | None) -> float:
|
def closeness_for(self, reason: str | None) -> float:
|
||||||
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
||||||
@@ -181,6 +184,8 @@ def rate_match(
|
|||||||
k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats}
|
k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats}
|
||||||
delta = {s.player: 0.0 for s in m.seats}
|
delta = {s.player: 0.0 for s in m.seats}
|
||||||
for a, b in combinations(m.seats, 2):
|
for a, b in combinations(m.seats, 2):
|
||||||
|
if p.skip_eliminated_pairs and a.eliminated and b.eliminated:
|
||||||
|
continue
|
||||||
if a.place > b.place:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
@@ -550,6 +555,7 @@ PROPOSED = Params(
|
|||||||
w_obj=0.5,
|
w_obj=0.5,
|
||||||
w_worlds=0.5,
|
w_worlds=0.5,
|
||||||
closeness=CLOSENESS,
|
closeness=CLOSENESS,
|
||||||
|
skip_eliminated_pairs=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -594,7 +600,7 @@ def examples() -> list[tuple[str, str, dict, dict, Match]]:
|
|||||||
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
||||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||||
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
||||||
("5", "Стол на 4: ничья выбывших, раунд 7",
|
("5", "Стол на 4: двое выбывших, раунд 7",
|
||||||
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
||||||
Match(
|
Match(
|
||||||
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
||||||
@@ -625,7 +631,7 @@ EXPECTED: dict[str, dict[str, float]] = {
|
|||||||
"4c": {"A": 4.8, "B": -4.8},
|
"4c": {"A": 4.8, "B": -4.8},
|
||||||
"4d": {"A": 3.4, "B": -3.4},
|
"4d": {"A": 3.4, "B": -3.4},
|
||||||
"4e": {"A": 16.0, "B": -16.0},
|
"4e": {"A": 16.0, "B": -16.0},
|
||||||
"5": {"A": 9.27, "B": 5.85, "C": -7.78, "D": -7.35},
|
"5": {"A": 9.27, "B": 5.85, "C": -7.6, "D": -7.53},
|
||||||
"6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
|
"6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
|
||||||
"6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
|
"6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
|
||||||
"7": {"A": 32.0, "B": -8.0},
|
"7": {"A": 32.0, "B": -8.0},
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Правила Forbidden Stars в Markdown
|
||||||
|
|
||||||
|
Текст правил, разбитый на логические блоки. Из него строится поиск по правилам (веха v1.6):
|
||||||
|
на блоки навешиваются теги и синонимы (#7), затем их индексирует Meilisearch (#10).
|
||||||
|
|
||||||
|
| Файл | Источник | Страниц | Блоков |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [rules.md](rules.md) | буклет «Правила игры» — `fs_rules_rus_web.pdf` | 16 | 184 |
|
||||||
|
| [reference.md](reference.md) | буклет «Справочник» — `fs_reference_rus_web.pdf` | 20 | 427 |
|
||||||
|
| [cards-faq.md](cards-faq.md) | «Уточнение карт», декабрь 2020 — `Уточнение_карт_Forbidden_Stars_декабрь2020.pdf` | 12 | 93 |
|
||||||
|
|
||||||
|
PDF в репозитории нет: они лежат вложениями в задаче #6. Правила и справочник — сканы без
|
||||||
|
текстового слоя, их текст распознан вручную по страницам. У «Уточнения карт» текстовый слой
|
||||||
|
есть, он взят за основу и сверен со страницами. Что не перенесено (обложки, художественный
|
||||||
|
текст, содержание), сказано в шапке каждого файла.
|
||||||
|
|
||||||
|
## Блок
|
||||||
|
|
||||||
|
Блок — наименьшая единица поиска: абзац под заголовком источника, пункт списка статьи
|
||||||
|
справочника или одна карта в уточнениях. Блок начинается маркером — HTML-комментарием,
|
||||||
|
который при отображении не виден:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<!-- block: rules.battle.retreat.attacker; page: 15 -->
|
||||||
|
#### Атакующий отступает
|
||||||
|
|
||||||
|
Когда атакующий отступает, он должен переместить…
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Граница блока — маркер.** Блок тянется до следующего маркера. Заголовок внутри блока
|
||||||
|
относится к нему. Если у абзаца нет своего заголовка, блок начинается прямо с текста.
|
||||||
|
- **Контекст блока** — цепочка заголовков над ним: `#` — файл, `##` — раздел или статья
|
||||||
|
глоссария, `###`/`####`/`#####` — подразделы. Первый блок раздела несёт его заголовок.
|
||||||
|
- **Нумерованные шаги** (подготовка к игре, шаги битвы) — отдельные блоки, заголовок шага
|
||||||
|
сохраняет номер источника: `### 2. Раунд битвы`, `#### а. Выберите Боевую Карту`.
|
||||||
|
- **Список** остаётся одним блоком вместе с вводной фразой, если пункты не имеют смысла
|
||||||
|
по отдельности. В справочнике каждый пункт статьи — самостоятельное правило, поэтому
|
||||||
|
каждый пункт — свой блок.
|
||||||
|
|
||||||
|
### Поля маркера
|
||||||
|
|
||||||
|
| Поле | Обязательно | Значение |
|
||||||
|
|---|---|---|
|
||||||
|
| `block` | да | постоянный id блока, уникальный во всей папке |
|
||||||
|
| `page` | да | страница PDF; `4-5`, если блок переходит на следующую страницу |
|
||||||
|
| `kind` | нет | тип не-правила (см. ниже); без `kind` блок — правило |
|
||||||
|
|
||||||
|
### Id
|
||||||
|
|
||||||
|
`<файл>.<раздел>[.<подраздел>…]` — латиница в нижнем регистре, слова через дефис, смысловые
|
||||||
|
английские слаги: `rules.orders.advance.limits`, `ref.retreats.defender.not-attacker-system`,
|
||||||
|
`faq.eldar.combat.psychic-lance`. Префикс: `rules.`, `ref.`, `faq.`. Вложенность id повторяет
|
||||||
|
вложенность заголовков, но не обязана совпадать с ней буквально.
|
||||||
|
|
||||||
|
**Id постоянны.** К ним привязываются теги, синонимы и ссылки «см. также» (#7), а через
|
||||||
|
них — поисковый индекс. Исправлять текст блока можно свободно. Id переименовывать нельзя:
|
||||||
|
новый блок получает новый id, а id удалённого блока больше не используется.
|
||||||
|
|
||||||
|
### `kind`
|
||||||
|
|
||||||
|
| Значение | Что это |
|
||||||
|
|---|---|
|
||||||
|
| `example` | врезка «Пример…» — иллюстрирует правило, но и сама может его уточнять |
|
||||||
|
| `caption` | подпись к схеме или иллюстрации. Если схема без подписи несёт информацию (например, раскладка тайлов), её описание составлено при переносе и выделено курсивом |
|
||||||
|
| `related` | строка «Связанные темы» из справочника — готовые связи между статьями |
|
||||||
|
|
||||||
|
## Текст
|
||||||
|
|
||||||
|
- Дословно, включая опечатки и авторскую пунктуацию источника. Ссылки «см. … на стр. N» —
|
||||||
|
номера страниц буклета.
|
||||||
|
- Переносы склеены, колонки и врезки выведены в порядке чтения.
|
||||||
|
- Выделение источника сохранено: жирный — `**…**`, курсив — `*…*`.
|
||||||
|
- Художественный текст (лор) не переносится.
|
||||||
|
|
||||||
|
## Значки
|
||||||
|
|
||||||
|
Значки внутри текста передаются словом в квадратных скобках. При отображении их можно
|
||||||
|
заменить картинкой, а для поиска они остаются словами.
|
||||||
|
|
||||||
|
| Запись | Значок |
|
||||||
|
|---|---|
|
||||||
|
| `[атака]`, `[защита]`, `[мораль]` | боевой значок на любом компоненте (мораль — боевой дух, аквила) |
|
||||||
|
| `[куб: атака]`, `[куб: защита]`, `[куб: мораль]` | значок на грани боевого куба |
|
||||||
|
| `[куб]` | куб, который нужно бросить |
|
||||||
|
| `[жетон: атака]`, `[жетон: защита]` | боевой жетон (значок в круге) |
|
||||||
|
| `[ресурс: кузница]`, `[ресурс: запасы]`, `[ресурс: подкрепление]`, `[ресурс: процветание]` | значок ценного ресурса на знамени мира |
|
||||||
|
| `[приказ: развертывание]`, `[приказ: планирование]`, `[приказ: доминирование]`, `[приказ: продвижение]` | жетон приказа |
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
Формальная проверка файлов (выполнялась при переносе, в репозиторий не входит): у каждого
|
||||||
|
маркера корректный формат, id уникален и имеет префикс своего файла, страница — в пределах
|
||||||
|
PDF, у каждого блока есть текст, каждая непропущенная страница дала хотя бы один блок, в
|
||||||
|
тексте только значки из таблицы выше, нет висячих переносов.
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
# Уточнение карт
|
||||||
|
|
||||||
|
> **Источник:** «Уточнения эффектов карт» — `Уточнение_карт_Forbidden_Stars_декабрь2020.pdf`, 12 стр.
|
||||||
|
> Уточнение по эффектам карт для перевода 2020 года. Автор: Дмитрий Wergg Комаров.
|
||||||
|
> Верстка: Андрей Yxo Паровой. Декабрь 2020.
|
||||||
|
> «Все возможные разночтения в эффектах карт решены на усмотрение автора. Пользуясь данной
|
||||||
|
> справкой, вы соглашаетесь с его виденьем по данному вопросу.»
|
||||||
|
>
|
||||||
|
> **Не перенесено:** стр. 1 (обложка), изображения карт и иллюстрации; выходные данные
|
||||||
|
> со стр. 12 — в этой шапке. Формат блоков и id — в [README](README.md).
|
||||||
|
|
||||||
|
## Общие положения
|
||||||
|
|
||||||
|
<!-- block: faq.general.effect-resolution; page: 2 -->
|
||||||
|
### Розыгрыш эффекта
|
||||||
|
|
||||||
|
- Если карта задает выбор, то выбор делается владельцем карты, если не сказано иного. Как в отношении своих компонентов, так и в отношении компонентов соперника.
|
||||||
|
- **Жирным** в тексте карт выделены смысловые акценты, которые легко пропустить.
|
||||||
|
- Владелец карты вправе отказаться разыгрывать эффект карты, кроме тех случаев, где эффект карты содержит слово **обязан**, выделенное жирным. От исполнения таких эффектов ни вы, ни ваш соперник не имеют права отказаться. Такие эффекты встречаются только в колоде Орков.
|
||||||
|
- Эффекты карт изложены в специальных полях. В боевых картах обычно 2 поля - зеленое и коричневое. В остальных картах поле одно. В одном поле может быть сложносоставной эффект. Решение о розыгрыше или отказе от розыгрыша должно приниматься для всего эффекта, описанного в поле.
|
||||||
|
- Если вы приняли решение разыграть эффект карты, то должны выполнить все предписания в поле настолько, насколько это возможно и именно в том порядке, в котором они перечислены.
|
||||||
|
- Нельзя отказаться от части эффекта карты, если вы приняли решение разыграть эффект.
|
||||||
|
- Если эффект подразумевает трату кубиков, то разыграть такой эффект можно ровно 1 раз, если только не оговорено, что потратить можно любое количество кубиков (и получить соответствующее количество активаций эффекта).
|
||||||
|
|
||||||
|
<!-- block: faq.general.may-avoid; page: 2 -->
|
||||||
|
### "Может избежать этого эффекта..."
|
||||||
|
|
||||||
|
- Карты с формулировкой **"Может избежать этого эффекта..."** построены по принципу: происходит событие **А**. Противник может избежать наступления события путем уплаты стоимости, указанной после слов **"Может избежать этого эффекта..."**
|
||||||
|
- Противник вправе отказаться уплачивать стоимость отмены. В этом случае событие **А** происходит.
|
||||||
|
- Если противник **не может** уплатить стоимость отмены в виду отсутствия необходимых ресурсов или в виду эффектов карт, то событие **А** происходит. Требуется именно уплата стоимости отмены в полном объеме, а не принципиальный факт согласия на уплату.
|
||||||
|
- Если противник согласился уплатить стоимость отмены, но не смог этого сделать в виду каких-либо эффектов карт или других причин, то событие **А** происходит.
|
||||||
|
|
||||||
|
<!-- block: faq.general.retreat; page: 2 -->
|
||||||
|
### Отступите / отступает
|
||||||
|
|
||||||
|
- Если эффект карты предписывает отступать, то отступление производится по общим правилам с соответствующими ограничениями (см. правила). Не забывайте, что после отступления отряд становится деморализованным.
|
||||||
|
- Отряд, отступивший в никем не контролируемую область, делает ее дружественной владельцу отряда.
|
||||||
|
- Если за бой было несколько отступлений, то они могут производиться в разные области.
|
||||||
|
- Если сражение началось по эффекту **карты** приказа, а не по самому приказу (карта "десантный модуль") или в результате действия боевых карт ("волновой змей"), то атакующий не сможет отступать (см правила отступления).
|
||||||
|
- Если сражение началось по приказу "продвижение", вызванному эффектом карты (карта орков "вперед!"), то отступление атакующих - легально.
|
||||||
|
|
||||||
|
<!-- block: faq.general.move; page: 2 -->
|
||||||
|
### Переместите
|
||||||
|
|
||||||
|
- Перемещать деморализованные отряды нельзя. Хоть по эффекту приказа, хоть по эффекту карты.
|
||||||
|
|
||||||
|
<!-- block: faq.general.take-and-place; page: 3 -->
|
||||||
|
### Забрать и поместить / разместить
|
||||||
|
|
||||||
|
- Забрать и поместить в другую область можно только недеморализованные отряды.
|
||||||
|
- При размещении игнорируется вместимость мира. Выбор, кого оставить производится после боя (если размещение было вызвано эффектом карты в бою) или после выполнения приказа (если размещение было сопряжено с приказом).
|
||||||
|
- При размещении отряда в бою игнорируется лимит в "5 отрядов максимум в одной области" т.к. этот лимит наложен только на перемещение.
|
||||||
|
- При размещении отряда в бою после броска кубиков новые кубики за вновь прибывший отряд уже не бросаются.
|
||||||
|
|
||||||
|
<!-- block: faq.general.replace; page: 3 -->
|
||||||
|
### Замените
|
||||||
|
|
||||||
|
- Т.к. в бою (и только в бою!) фишки подкрепления являются отрядами, то заменять их на пластиковые фигурки - легально.
|
||||||
|
- Процедура "заменить" не равна "купить". При замещении игнорируется командный уровень.
|
||||||
|
- Можно заменить деморализованный отряд на недеморализованный.
|
||||||
|
|
||||||
|
<!-- block: faq.general.purchase; page: 3 -->
|
||||||
|
### Купите
|
||||||
|
|
||||||
|
- Только процедура покупки, инициированная жетоном приказа "развертывание" требует наличия фабрики для приобретения отрядов.
|
||||||
|
- Если эффект карты позволяет вам покупать отряды, то наличие фабрики не требуются. Однако командный уровень все еще учитывается.
|
||||||
|
|
||||||
|
<!-- block: faq.general.gain; page: 3 -->
|
||||||
|
### Получите
|
||||||
|
|
||||||
|
- "Получить" не равно "Купить". Получение не требует соблюдения командного уровня.
|
||||||
|
|
||||||
|
<!-- block: faq.general.command-level; page: 3 -->
|
||||||
|
### Командный уровень
|
||||||
|
|
||||||
|
- Ваш фактический командный уровень равен числу ваших городов.
|
||||||
|
- Жетоны кузницы дают вам временное понижение командного уровня покупаемого отряда, сам командный уровень отрядов не меняется. Фактически командный уровень отрядов не уменьшается и всегда равен написанному на листе фракции.
|
||||||
|
- Если эффект карты ссылается на командный уровень отряда или улучшения, то имеется в виду именно то, что написано фактически на листе фракции или карте.
|
||||||
|
- Если эффект предписывает вам получить (а так же разместить или заменить) что-либо, то командный уровень игнорируется.
|
||||||
|
|
||||||
|
<!-- block: faq.general.orbital-strike; page: 3 -->
|
||||||
|
### Орбитальный удар
|
||||||
|
|
||||||
|
- Помните про лимит в 8 кубиков.
|
||||||
|
- Если способность предписывает потратить кубики во время выполнения орбитального удара, то это нужно сделать **до** назначения урона.
|
||||||
|
- Если эффект подразумевает трату кубиков, то разыграть такой эффект можно ровно 1 раз (воруй, грабь, десантный модуль, огненный дождь, обстрел, расчетливый удар, полное разрушение, страх с небес).
|
||||||
|
- Несмертельный урон не приводит к деморализации (в отличии от боя).
|
||||||
|
- Вы в праве объявить орбитальный удар по миру без отрядов (и даже без бастиона) для активации способности.
|
||||||
|
- Нельзя объявить орбитальный удар по никем не контролируемому или своему миру.
|
||||||
|
- Нельзя объявить орбитальный удар по области без миров.
|
||||||
|
- Нельзя объявить орбитальный удар по миру в соседней системе.
|
||||||
|
|
||||||
|
## Орки
|
||||||
|
|
||||||
|
<!-- block: faq.orks.faction-ability; page: 4 -->
|
||||||
|
### Фракционная способность
|
||||||
|
|
||||||
|
Вы можете купить только наземный отряд. Для покупки отряда **не** требуется наличие фабрики. Командный уровень покупаемого отряда учитывается. Вы можете использовать жетон запасов (шестеренки), чтобы уменьшить стоимость на 2. Вы можете использовать жетон кузницы (молот), чтобы снизить требование по командному уровню покупаемого отряда.
|
||||||
|
|
||||||
|
### События
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.more-boyz; page: 4 -->
|
||||||
|
#### Больше бойзов!
|
||||||
|
|
||||||
|
Вы не можете иметь более 3 жетонов 1 типа. Лишние жетоны подкрепления придется сразу сбросить.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.into-battle; page: 4 -->
|
||||||
|
#### В бой!
|
||||||
|
|
||||||
|
При размещении отрядов вы можете превысить лимит мира (количество черепов). Решение, какие отряды оставить в этом мире, принимается после размещения.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.how-did-we-get-here; page: 4 -->
|
||||||
|
#### Как мы сюда попали?
|
||||||
|
|
||||||
|
забрать и поместить можно только недеморализованный отряд. Разместить таким образом можно только наземный отряд.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.looting; page: 4 -->
|
||||||
|
#### Мародерство
|
||||||
|
|
||||||
|
Вы получите материалы, даже если у выбранного противника нет жетонов ценных ресурсов. У вас не может быть больше 14 материалов.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.tear-it-down; page: 4 -->
|
||||||
|
#### Снести это!
|
||||||
|
|
||||||
|
Вы можете уничтожить любую постройку (не только бастион).
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.finish-them; page: 4 -->
|
||||||
|
#### Добить их!
|
||||||
|
|
||||||
|
Для того чтобы воспользоваться эффектом данной карты отряд противника должен стать деморализованным фактически. Если какой-либо эффект предотвратил деморализацию отрядов противника (напр. карта " не ведать страха" фракции Ультрамаринов), то уничтожить такой отряд при помощи этой карты нельзя.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.warboss; page: 4 -->
|
||||||
|
#### Варбосс
|
||||||
|
|
||||||
|
Скидка действует как при покупке при помощи приказа "развертывание", так и при покупке по эффекту карт или фракционной способности. Вы не можете иметь более 3 жетонов подкрепления.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.events.forward; page: 4 -->
|
||||||
|
#### Вперед!
|
||||||
|
|
||||||
|
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой. При выполнении эффекта этой карты считайте, что вы вскрыли приказ "продвижение" в целевой системе. Т.к. считается, что сражение, начавшееся после розыгрыша данной карты, произошло по приказу "продвижение", то отступление атакующего - легально (см правила отступления).
|
||||||
|
|
||||||
|
### Приказы
|
||||||
|
|
||||||
|
<!-- block: faq.orks.orders.build-faster; page: 5 -->
|
||||||
|
#### Строй быстрее!
|
||||||
|
|
||||||
|
После постройки фабрики можно будет сразу же приобретать отряды в этой системе.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.orders.plunder; page: 5 -->
|
||||||
|
#### Грабь!
|
||||||
|
|
||||||
|
Вы не можете бросить более 8 кубиков для орбитального удара, все лишние кубики пропадут. Если у противника нет материалов, то вы все равно получите 1 материал. Если у вас уже 14 материалов, то противник все равно потеряет 1 материал (если вы потратите кубик, конечно).
|
||||||
|
|
||||||
|
<!-- block: faq.orks.orders.green-tide; page: 5 -->
|
||||||
|
#### Зеленая волна
|
||||||
|
|
||||||
|
Вы должны выбрать тип разыгрываемого приказа при вскрытии жетона. Считайте, что на данном жетоне изображена иконка выбранного вами приказа. Если вы выбрали приказ, отличный от приказа "планирование", то не размещайте этот жетон приказа поверх колоды событий.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.orders.steal; page: 5 -->
|
||||||
|
#### Воруй!
|
||||||
|
|
||||||
|
Если у противника нет жетонов ценных ресурсов, то вы не получите ничего. Если у вас уже 3 жетона выбранного ресурса, то противник все равно потеряет выбранный жетон (вы при этом четвертый жетон не получите).
|
||||||
|
|
||||||
|
<!-- block: faq.orks.orders.ork-ships; page: 5 -->
|
||||||
|
#### Орочьи корабли
|
||||||
|
|
||||||
|
Вы можете составить путь из настоящих кораблей и дружественных миров в любой пропорции и дополнительно **однократно** провести не более 2 наземных отрядов через пустую область как через дружественную. Переместить по приказу можно больше 2 отрядов, главное, чтобы именно через пустую область без кораблей по эффекту данной карты было перемещено не более 2 отрядов. Нельзя отступать через пустую область при помощи данной карты в защите. Нельзя отступать при помощи данной карты в атаке, если вы использовали ее для перемещения в оспариваемую область. Можно отступить при помощи данной карты в атаке (с соблюдением всех правил отступления), если вы не использовали ее для перемещения в оспариваемую область.
|
||||||
|
|
||||||
|
### Боевые карты
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.hard-boyz; page: 5 -->
|
||||||
|
#### Хард бойз
|
||||||
|
|
||||||
|
Оба эффекта карты обязательны к исполнению и от них нельзя отказаться.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.mega-nobz; page: 5 -->
|
||||||
|
#### Мега нобз
|
||||||
|
|
||||||
|
верхний эффект обязателен к исполнению и от него нельзя отказаться.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.weirdboyz; page: 5 -->
|
||||||
|
#### Вирдбойз
|
||||||
|
|
||||||
|
верхний эффект обязателен к исполнению и от него нельзя отказаться. Если будет сыграно две карты **вирдбойз**, то их свойства будут складываться, т.е. вы будете получать удвоенное количество жетонов.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.slugga-boyz; page: 5 -->
|
||||||
|
#### Слагга бойз
|
||||||
|
|
||||||
|
Верхний эффект обязателен к исполнению и от него нельзя отказаться.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.biker-nobz; page: 5 -->
|
||||||
|
#### Байкер нобз
|
||||||
|
|
||||||
|
верхний эффект обязателен к исполнению и от него нельзя отказаться.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.mek-boyz; page: 5 -->
|
||||||
|
#### Мек бойз
|
||||||
|
|
||||||
|
Противник сбрасывает карту с **колоды** (не с руки). Вы получаете именно значки (не жетоны). Это может быть важно для эффектов карт, заставляющих сбрасывать боевые жетоны. Если карта была сброшена таким образом в 3 раунде боя, то аквилы (орлы) со сброшенной карты не участвуют в подсчете общего количества морали, т.к. он производится **после** окончания 3 раунда.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.shoota-boyz; page: 5 -->
|
||||||
|
#### Шута бойз
|
||||||
|
|
||||||
|
Оба эффекта карты обязательны к исполнению и от них нельзя отказаться.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.ripping-gargant; page: 5 -->
|
||||||
|
#### Разрывающий гаргант
|
||||||
|
|
||||||
|
если сброшенная карта давала свойство, действующее в течение этого раунда или всего боя, то оно (свойство) немедленно прекращается.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.party-wagon; page: 5 -->
|
||||||
|
#### Пати вагон
|
||||||
|
|
||||||
|
Т.к. жетон размещается бесплатно, то он берется из резерва (а не из вашего запаса). Ограничение на 3 жетона одного типа при этом соответственно игнорируется. Также игнорируется правило, что у вы не можете разместить больше жетонов подкрепления, чем пластиковых фигурок в бою на вашей стороне.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.gretchin; page: 5 -->
|
||||||
|
#### Гретчины
|
||||||
|
|
||||||
|
Несмотря на слово "должен" вы можете не разыгрывать верхний эффект карты. Но тогда вы не получите боевые жетоны.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.crushing-gargant; page: 5 -->
|
||||||
|
#### Сокрушающий гаргант
|
||||||
|
|
||||||
|
если у противника нет необходимого количества кубиков или он не хочет их тратить, то выбранный отряд уничтожается.
|
||||||
|
|
||||||
|
<!-- block: faq.orks.combat.sea-of-green; page: 5 -->
|
||||||
|
#### Море зеленых
|
||||||
|
|
||||||
|
1. Т.к. жетон размещается бесплатно, то он берется из резерва (а не из вашего запаса). Ограничение на 3 жетона одного типа при этом соответственно игнорируется. Также игнорируется правило, что у вы не можете разместить больше жетонов подкрепления, чем фигурок в бою на вашей стороне.
|
||||||
|
2. Если у противника нет кубиков с аквилами (орлами) или он не хочет их тратить, то он должен будет деморализовать 1 свой отряд (если сможет).
|
||||||
|
|
||||||
|
## Космодесант Хаоса
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.faction-ability; page: 6 -->
|
||||||
|
### Фракционная способность
|
||||||
|
|
||||||
|
Т.к. это размещение, а не перемещение, то целевая планета может не иметь легального пути до места изначального размещения культиста и даже может находиться за варп-штормом. Забрать и разместить таким образом можно только недеморализованный отряд.
|
||||||
|
|
||||||
|
### События
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.events.warp-touched; page: 6 -->
|
||||||
|
#### Задетый варпом
|
||||||
|
|
||||||
|
Т.к. вы получаете, а не покупаете улучшение, то ваш фактический командный уровень (число городов) не имеет значения.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.events.nurgle-rot; page: 6 -->
|
||||||
|
#### Отрава Нургла
|
||||||
|
|
||||||
|
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.events.tzeentch-incarnation; page: 6 -->
|
||||||
|
#### Инкарнация Тзинча
|
||||||
|
|
||||||
|
Если эта карта была вытянута в числе нескольких карт в 3 фазе (если на колоде событий лежало более 1 жетона) и была разыграна, то сначала тянуться еще 3 карты и играется одна из них, а только потом все карты замешиваются в колоду событий.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.events.through-the-warp; page: 6 -->
|
||||||
|
#### Сквозь варп
|
||||||
|
|
||||||
|
Вы получите дополнительный кубик с аквилой (орлом) даже если не пересекали варп-шторм перед началом сражения. Помните про лимит в 8 кубиков. Т.к. вы получите кубик с аквилой еще до броска основных кубиков, то фактически вы не сможете бросить больше 7 кубиков (восьмой кубик будет аквилой). Вы можете отступить через варп-шторм после атаки. Вы не можете использовать эту карту для отступления в защите.
|
||||||
|
|
||||||
|
### Приказы
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.orders.from-the-warp; page: 6 -->
|
||||||
|
#### Из варпа
|
||||||
|
|
||||||
|
Наземные отряды **не** могут пересекать варп-штормы по эффекту данной карты. Вы можете отступить через варп-шторм после атаки. Вы не можете использовать эту карту для отступления в защите.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.orders.dark-gods-favor; page: 6 -->
|
||||||
|
#### Расположение темных богов
|
||||||
|
|
||||||
|
Дополнительные жетоны нужно взять из числа неиспользуемых в этом раунде (не с поля). В комбинации с картой **инкарнация Тзинча** можно просмотреть 6 карт из колоды событий.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.orders.dread-ritual; page: 6 -->
|
||||||
|
#### Ужасный ритуал
|
||||||
|
|
||||||
|
Для покупки по эффекту данной карты не требуется завод. Вы можете купить корабль по эффекту данной карты, но разместить его нужно обязательно в дружественной области активной системы. Т.к. вы **покупаете** отряд, то ваш командный уровень все равно учитывается. Вы можете использовать жетон кузницы (молоток), если вам не хватает вашего текущего командного уровня для приобретения отряда. Вы не можете покупать титанов по эффекту данной карты, т.к. наложен запрет на покупку отрядов выше фактического второго уровня.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.orders.terror-from-the-skies; page: 6 -->
|
||||||
|
#### Страх с небес
|
||||||
|
|
||||||
|
Не забывайте, что во время орбитального удара несмертельный урон не приводит к деморализации. Помните про лимит в 8 кубиков.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.orders.total-destruction; page: 6 -->
|
||||||
|
#### Полное разрушение
|
||||||
|
|
||||||
|
Противник должен выбрать для уничтожения любую пластиковую фигурку (отряд или строение) **из имеющихся** в мире, подвергшемся орбитальному удару. Противник вправе выбрать небоевое строение (город/фабрика). Потратить кубики и разыграть способность нужно **до** назначения урона. Несмертельный урон не приводит к деморализации отряда. Вы в праве объявить орбитальный удар по враждебному миру без отрядов и/или бастиона для активации этой способности.
|
||||||
|
|
||||||
|
### Боевые карты
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.wrath-of-khorne; page: 7 -->
|
||||||
|
#### Ярость Кхорна
|
||||||
|
|
||||||
|
Если у противника нет кубика защиты (щит) или он не хочет его тратить, то он должен выбрать и деморализовать свой отряд. Если у противника нет недеморализованных отрядов или они не могут стать деморализованными (карта "не ведать страха"), то не произойдет ничего.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.daemonic-resilience; page: 7 -->
|
||||||
|
#### Демоническая стойкость
|
||||||
|
|
||||||
|
Если у противника нет отрядов для уничтожения или он не хочет этого делать, то вы получите жетоны защиты.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.mark-of-tzeentch-top; page: 7 -->
|
||||||
|
#### Отметка Тзинча (верх)
|
||||||
|
|
||||||
|
можно заменить деморализованный отряд культистов на нормальный отряд космодесанта хаоса. Можно заменить жетон подкрепления на фигурку космодесанта хаоса, т.к. в бою жетон подкрепления является отрядом (культистов).
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.mark-of-tzeentch-bottom; page: 7 -->
|
||||||
|
#### Отметка Тзинча (низ)
|
||||||
|
|
||||||
|
можно выбрать разные грани кубиков, если потрачены 2 аквилы.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.mark-of-slaanesh-top; page: 7 -->
|
||||||
|
#### Отметка Слаанеш (верх)
|
||||||
|
|
||||||
|
если у противника нет недеморализованных отрядов или они не могут стать деморализованными, то не произойдет ничего.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.mark-of-khorne-bottom; page: 7 -->
|
||||||
|
#### Отметка Кхорна (низ)
|
||||||
|
|
||||||
|
если у противника нет деморализованных отрядов, то не произойдет ничего. Если у противника есть деморализованные отряды, но нет кубика защиты (щит) или он не хочет его тратить, то он должен выбрать и уничтожить свой деморализованный отряд.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.death-and-despair-top; page: 7 -->
|
||||||
|
#### Смерть и отчаяние (верх)
|
||||||
|
|
||||||
|
нельзя выбрать 1 болтер и 1 аквилу. Только либо 2 болтера, либо 2 аквилы. Выбор, какой отряд уничтожать, делается игроком за Хаос.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.lures-of-chaos-top; page: 7 -->
|
||||||
|
#### Соблазны хаоса (верх)
|
||||||
|
|
||||||
|
противник вправе выбрать деморализовать 1 свой отряд даже если у него нет недеморализованных отрядов или они не могут стать деморализованными (карта "не ведать страха"). Это приведет к тому, что хаос не получит культиста (т.к. выбран другой вариант, пусть и невыполнимый), а противник хаоса не получит кубик (т.к. он не "уплатил" цену этого кубика - фактически не деморализовал отряд).
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.mark-of-nurgle-bottom; page: 7 -->
|
||||||
|
#### Отметка Нургла (низ)
|
||||||
|
|
||||||
|
если у противника нет деморализованных отрядов или он не хочет их уничтожать, то вы получите жетоны защиты.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.unholy-intent-bottom; page: 7 -->
|
||||||
|
#### Нечистые намерения (низ)
|
||||||
|
|
||||||
|
Если у противника нет недеморализованных отрядов, или они не могут стать деморализованными (карта "не ведать страха"), или он не хочет деморализовать свой отряд, то хаос получит жетоны атаки. Т.е. избежать получения жетонов атаки можно только фактически деморализовав свой отряд.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.dark-faith; page: 7 -->
|
||||||
|
#### Темная вера
|
||||||
|
|
||||||
|
размещенный таким образом культист может отрезать легальный путь к отступлению противника.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.chaos-united-top; page: 7 -->
|
||||||
|
#### Хаос объединенный (верх)
|
||||||
|
|
||||||
|
Противник может выбрать деморализацию своих отрядов даже если у него нет недеморализованных отрядов или они не могут стать деморализованными ( карта "не ведать страха"). Такой выбор не приведет к тому, что Хаос получит кубик. Противник обязан сделать выбор не зная, кубик с каким значением возьмет игрок за Хаос в случае отказа.
|
||||||
|
|
||||||
|
<!-- block: faq.chaos.combat.chaos-united-bottom; page: 7 -->
|
||||||
|
#### Хаос объединенный (низ)
|
||||||
|
|
||||||
|
Можно превысить вместимость мира и даже превысить планку в 5 отрядов в бою т.к. это не перемещение, а размещение.
|
||||||
|
|
||||||
|
## Ультрамарины
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.faction-ability; page: 8 -->
|
||||||
|
### Фракционная способность
|
||||||
|
|
||||||
|
Можно заменить деморализованный отряд на недеморализованный более высокого уровня. Командный уровень при замене игнорируется. Нельзя использовать жетон запасов (шестеренки) для активации этого свойства, т.к. это не покупка.
|
||||||
|
|
||||||
|
### События
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.events.exterminatus; page: 8 -->
|
||||||
|
#### Экстерминатус
|
||||||
|
|
||||||
|
Вы должны заявить эффект данной карты до броска кубиков. Нельзя сначала бросить кубики и посмотреть результат, а затем сыграть эту карту.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.events.steadfast; page: 8 -->
|
||||||
|
#### Непоколебимость
|
||||||
|
|
||||||
|
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.events.pre-battle-ceremony; page: 8 -->
|
||||||
|
#### Церемония перед боем
|
||||||
|
|
||||||
|
Т.к. это покупка, то командный уровень учитывается. Купить можно только улучшение приказа (не боевое улучшение). Скидка дается именно за миры с бастионами, а не сами бастионы.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.events.emperors-protection; page: 8 -->
|
||||||
|
#### Защита Императора
|
||||||
|
|
||||||
|
Т.к. это не строительство, а размещение, то второй вариант розыгрыша карты не может быть оплачен жетоном запасов (шестеренки). За 2 материала вы не покупаете бастион, а активируете способность карты, которая даст вам бастион.
|
||||||
|
|
||||||
|
### Приказы
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.orders.crusade; page: 8 -->
|
||||||
|
#### Крестовый поход
|
||||||
|
|
||||||
|
Вы не обязаны использовать полученный жетон в этой битве. Помните про лимит в 3 жетона одного типа. Вы получите жетон подкрепления, если вы нападаете из одного мира в другой в пределах одной системы и переместили все наземные войска в оспариваемую область.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.orders.recruiting-worlds; page: 8 -->
|
||||||
|
#### Миры призыва
|
||||||
|
|
||||||
|
Чтобы не ошибиться с подсчетом суммарного лимита следует сначала сложить все лимиты миров с фабриками и бастионами в системе. Если в 1 мире находятся и фабрика и бастион или 2 бастиона, то лимит этого мира следует считать дважды (как будто это 2 фабрики, см. эффект карты **направлять верующих**). После этого из получившейся суммы следует вычесть количество бастионов в системе.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.orders.guide-the-faithful; page: 8 -->
|
||||||
|
#### Направлять верующих
|
||||||
|
|
||||||
|
Можно создавать миры с одинаковыми постройками. Если будет создан мир с 2 фабриками, то лимит развертывания будет равен удвоенному количеству черепов этого мира.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.orders.drop-pod; page: 8 -->
|
||||||
|
#### Десантный модуль
|
||||||
|
|
||||||
|
Вы вправе объявить орбитальный удар по миру противника без отрядов и/или бастиона для активации свойства. Сочетание этой карты и карты **крестовый поход** даст вам жетон подкрепления после высадки космодесантника, если остальные условия получения жетона будут соблюдены. Если космодесантник в бою, инициированном эффектом данной карты, должен будет отступить, то его придется уничтожить (см. правила отступления).
|
||||||
|
|
||||||
|
### Боевые карты
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.combat.know-no-fear; page: 9 -->
|
||||||
|
#### Не ведать страха
|
||||||
|
|
||||||
|
Вы должны выбрать будете ли вы использовать верхнее свойство карты в момент ее вскрытия. Если эффект карты активирован, то вы не сможете уплатить цену эффектов с ключевыми словами "деморализуйте свой отряд". Вы все еще вправе выбирать варианты, подразумевающие деморализацию ваших отрядов, если такой выбор задается. В этом случае вы не сможете деморализовать свой отряд, но это и не приведет к автоматическому выбору второго варианта. Подробнее сочетание этой карты с другими смотрите в соответствующих уточнениях (Хаос объединенный, Соблазны хаоса, Ярость Кхорна, Нечистые намерения, Море зеленых)
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.combat.veteran-scouts; page: 9 -->
|
||||||
|
#### Скауты ветераны
|
||||||
|
|
||||||
|
Т.к. проверка окончания боя будет только в конце шага нанесения боевых повреждений, то противнику придется распределять урон в свои отряды даже если все ваши отряды выйдут из боя. Достаточно 1 недеморализованного скаута, чтобы отступить несколькими отрядами (за аналогичное количество кубиков с аквилами). После отступления отряд становится деморализованным. Отступить можно и деморализованным отрядом. Отряд, отступивший в никем не контролируемую область, делает ее дружественной. Отступление по данной карте должно производиться в 1 область. Если будет разыграна вторая карта **скауты ветераны**, то отступление можно будет производить в другую область.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.combat.ambush; page: 9 -->
|
||||||
|
#### Засада
|
||||||
|
|
||||||
|
Трата кубиков с аквилами не предотвращает уничтожение отрядов другим способом (например, от смертельного урона или эффектов карт с ключевым словом "уничтожьте"). Если **засада** разыграна в защите, а игрок за Эльдар ранее в этом раунде разыграл **наставления видящих**, то отряд Эльдар не уничтожается, т.к. эффект засады действует от момента раскрытия карты.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.combat.drop-assault; page: 9 -->
|
||||||
|
#### Десант
|
||||||
|
|
||||||
|
Можно забрать и поместить только недеморализованный отряд
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.combat.power-fist; page: 9 -->
|
||||||
|
#### Бронекулак
|
||||||
|
|
||||||
|
Противник тоже наносит урон. Урон между шагами не складывается. Поэтому если с 1 стороны остался только 1 отряд с выносливостью 3 и против него нужно дважды разыграть урон 2, то после первого шага отряд станет деморализованным, а второй шаг не сделает ничего.
|
||||||
|
|
||||||
|
<!-- block: faq.ultramarines.combat.recon; page: 9 -->
|
||||||
|
#### Разведка
|
||||||
|
|
||||||
|
Т.к. проверка окончания боя будет только в конце шага нанесения боевых повреждений, то противнику придется распределять урон в свои отряды даже если все ваши отряды выйдут из боя. По эффекту данной карты отступить могут любые отряды, а не только скаут/ударный крейсер. После отступления отряд становится деморализованным. Отступить можно и деморализованным отрядом. Отряд, отступивший в никем не контролируемую область, делает ее дружественной. Отступление по данной карте должно производиться в 1 область. Если будет разыграна вторая карта **Разведка**, то отступление можно будет производить в другую область.
|
||||||
|
|
||||||
|
## Эльдар
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.faction-ability; page: 10 -->
|
||||||
|
### Фракционная способность
|
||||||
|
|
||||||
|
Можно превысить лимит мира при размещении отряда. Выбор, кого оставить, производится после размещения. Разместить таким образом можно только наземные отряды. Вам не требуется легальный путь, т.к. это не перемещение, а именно размещение. Забрать и поместить в другую область деморализованный отряд нельзя.
|
||||||
|
|
||||||
|
### События
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.events.legacy-of-vaul; page: 10 -->
|
||||||
|
#### Наследие Ваула
|
||||||
|
|
||||||
|
У вас не может быть более 3 жетонов 1 типа. Лишние придется сбросить.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.events.exodite-colony; page: 10 -->
|
||||||
|
#### Колония экзодитов
|
||||||
|
|
||||||
|
Данное действие не является строительством, это бесплатное размещение города, где цена в 1 материал – это цена активации способности. Нельзя потратить жетон запасов (шестеренки) на активацию этого свойства, т.к. это не строительство.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.events.terror-raid; page: 10 -->
|
||||||
|
#### Ужасный налет
|
||||||
|
|
||||||
|
Помните про лимит в 8 кубиков. Если вы должны получить девятый вы вместо этого не получаете кубик.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.events.path-of-the-warrior; page: 10 -->
|
||||||
|
#### Путь воина
|
||||||
|
|
||||||
|
Купить можно только боевое улучшение колоды (не улучшение приказа). Улучшение приказа таким образом купить нельзя. Скидка считается именно по количеству миров (планет) с городами, а не количеству городов. Командный уровень при покупке учитывается.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.events.warp-gate; page: 10 -->
|
||||||
|
#### Врата варпа
|
||||||
|
|
||||||
|
Для того, чтобы воспользоваться этой картой вы должны иметь возможность вскрыть жетон приказа. Если все ваши жетоны приказов заблокированы жетонами соперников или ваши жетоны уже закончились, то вы не можете воспользоваться этой картой. Забрать и поместить в другую область деморализованный отряд нельзя.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.events.foresight; page: 10 -->
|
||||||
|
#### Предвидение
|
||||||
|
|
||||||
|
Забрать и поместить в другую область деморализованные отряды нельзя. Проверка окончания боя будет только в конце шага нанесения боевых повреждений, значит противнику придется распределять урон в свои отряды (не смотря на то, что все отряды Эльдар выйдут из боя). Игроку за Эльдар урон распределять не нужно, если все его отряды выйдут из боя. Если у игрока за Эльдар в бою останется бастион и/или деморализованные отряды, то урон от противника достанется им, а бой продолжится.
|
||||||
|
|
||||||
|
### Приказы
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.orders.bombardment; page: 10 -->
|
||||||
|
#### Обстрел
|
||||||
|
|
||||||
|
Деморализованные отряды перемещать нельзя.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.orders.calculated-strike; page: 10 -->
|
||||||
|
#### Расчетливый удар
|
||||||
|
|
||||||
|
Помните про лимит в 8 кубиков. Если вы должны получить девятый, вы вместо этого не получаете кубик.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.orders.corsair-raid; page: 10 -->
|
||||||
|
#### Рейд корсаров
|
||||||
|
|
||||||
|
Нельзя выполнить 2 орбитальных удара по эффекту данной карты.
|
||||||
|
|
||||||
|
### Боевые карты
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.hit-and-run; page: 11 -->
|
||||||
|
#### Бей и беги
|
||||||
|
|
||||||
|
Деморализованные отряды перемещать нельзя. Можно переместиться в соседнюю систему, главное, чтобы область назначения была соседней (примыкала ортогонально). Таким перемещением можно отрезать легальный путь для отступления противнику.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.howling-banshees; page: 11 -->
|
||||||
|
#### Воющие баньши
|
||||||
|
|
||||||
|
Если у противника нет отрядов для деморализации или они не могут стать деморализованными по эффекту карт, то не произойдет ничего.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.ranger-cover; page: 11 -->
|
||||||
|
#### Прикрытие рейнджеров
|
||||||
|
|
||||||
|
После отступления отряд становится деморализованным. Отступить можно и деморализованным отрядом. Отряд, отступивший в никем не контролируемую область, делает ее дружественной. Отступление по данной карте должно производиться в 1 область. Если будет разыграна вторая карта **прикрытие рейнджеров**, то отступление можно будет производить в другую область.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.autarch-guidance; page: 11 -->
|
||||||
|
#### Руководство Аутарха
|
||||||
|
|
||||||
|
Помните про лимит в 8 кубиков. Если вы не можете разыграть свойство на получение кубика или восстановление подразделения, то это не запрещает вам разыграть свойство на розыгрыш дополнительной карты.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.wraithguard-advance; page: 11 -->
|
||||||
|
#### Наступление призрачных стражей
|
||||||
|
|
||||||
|
Если у противника нет кубиков с аквилами (орлами), то он должен деморализовать свой отряд (если есть и если это возможно). Если у противника нет недеморализованных отрядов или его отряды не могут стать деморализованными, то не произойдет ничего (противник не обязан тратить кубик с аквилой).
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.seer-counsel; page: 11 -->
|
||||||
|
#### Наставления видящих
|
||||||
|
|
||||||
|
Противник тоже не получит повреждений. Карта не запрещает деморализовать отряды и напрямую их уничтожать по эффектам других карт. Блокируются только боевые повреждения.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.wave-serpent-top; page: 11 -->
|
||||||
|
#### Волновой змей (верхнее свойство)
|
||||||
|
|
||||||
|
Если у противника нет кубиков с аквилами (орлами), то Эльдар получат жетоны защиты.
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.wave-serpent-bottom; page: 11 -->
|
||||||
|
#### Волновой змей (нижнее свойство)
|
||||||
|
|
||||||
|
Деморализованные отряды перемещать нельзя. Можно переместиться в соседнюю систему, главное, чтобы область назначения была соседней (примыкала ортогонально). Такое перемещение может отрезать легальный путь к отступлению сопернику. Отряды Эльдар не смогут отступать из боя, вызванного этим эффектом (см правила отступления).
|
||||||
|
|
||||||
|
<!-- block: faq.eldar.combat.psychic-lance; page: 11 -->
|
||||||
|
#### Психокопье
|
||||||
|
|
||||||
|
Формулировка карты переписана (по сравнению с английской версией). Карта задает выбор: ИЛИ владелец психокопья получает 4 жетона атаки (**А**), ИЛИ противник **соглашается** на то, что владелец психокопья выберет открытую боевую карту соперника и сбросит ее (прикажет ее сбросить) (**Б**). Если у противника нет открытых боевых карт, то это никак не влияет на саму принципиальную возможность **согласиться** на эту процедуру. Противник вправе выбрать вариант **Б**, даже если у него нет отрытых боевых карт (например, идет 1 раунд боя и Эльдар атакуют). В таком случае игрок за Эльдар не получит боевые жетоны, а противник Эльдар не потеряет ничего, т.к. ему нечего терять.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
|||||||
|
# Правила игры
|
||||||
|
|
||||||
|
> **Источник:** буклет «Правила игры» — `fs_rules_rus_web.pdf`, 16 стр. (скан без текстового
|
||||||
|
> слоя, распознан вручную). Перевод: Копылов Олег.
|
||||||
|
>
|
||||||
|
> **Не перенесено:** стр. 1 (обложка); художественный текст — «Нет мира среди звезд» и описания
|
||||||
|
> фракций (стр. 2), вводный абзац врезки «Что такое варп-штормы?» (стр. 7); иллюстрации. От схем оставлены подписи; описание схемы, составленное
|
||||||
|
> при переносе, выделено курсивом. Формат блоков, id и обозначения иконок — в [README](README.md).
|
||||||
|
|
||||||
|
<!-- block: rules.overview; page: 2 -->
|
||||||
|
## Обзор игры
|
||||||
|
|
||||||
|
*Forbidden Stars* — игра о галактической войне, действие которой происходит во вселенной *Warhammer 40,000.* От двух до четырех игроков командуют Космодесантниками, Орками, Эльдарами или Космодесантниками Хаоса (в базовой игре). Каждая фракция стремится вернуть потерянные реликвии и места силы, которые являются ключевыми для их выживания. Для этого они должны собирать ресурсы, улучшать свои силы и завоевывать миры давно потерянного Скопления Геракон.
|
||||||
|
|
||||||
|
<!-- block: rules.overview.using-this-booklet; page: 2 -->
|
||||||
|
### Использование этого буклета
|
||||||
|
|
||||||
|
Этот буклет «Правила Игры» предназначен для обучения новых игроков игре в Forbidden Stars. Чтобы научить игре быстро и просто, в этом буклете опущены многие исключения из правил и сложные игровые взаимодействия из-за большого количества типов отрядов и боевых карт. Игроки должны использовать прилагающийся Справочник, чтобы разрешить эти ситуации.
|
||||||
|
|
||||||
|
<!-- block: rules.factions; page: 2 -->
|
||||||
|
## Фракции
|
||||||
|
|
||||||
|
В *Forbidden Stars* представлены четыре основные фракции вселенной Warhammer 40,000. Каждая фракция имеет свои уникальные компоненты, которые можно отличить по цвету или по символу фракции. Это позволяет определить, какие компоненты принадлежат каким игрокам.
|
||||||
|
|
||||||
|
- **Орден Космодесантников Ультрамарины**
|
||||||
|
- **Легион-Предатель Космодесантников Хаоса Пожиратели Миров**
|
||||||
|
- **Мир-Корабль Эльдар Йанден**
|
||||||
|
- **Орки Клана Злосолнца**
|
||||||
|
|
||||||
|
<!-- block: rules.components; page: 3 -->
|
||||||
|
## Список компонентов
|
||||||
|
|
||||||
|
- 1 Справочник
|
||||||
|
- 112 Боевых Карт (28 на фракцию)
|
||||||
|
- 32 Карт Событий (8 на фракцию)
|
||||||
|
- 20 Карт Улучшения Приказов (5 на фракцию)
|
||||||
|
- 24 Маркера Целей (6 на фракцию)
|
||||||
|
- 36 Маркеров Контроля Построек (9 на фракцию)
|
||||||
|
- 32 Жетона Приказов (8 на фракцию)
|
||||||
|
- 4 Карты Справок
|
||||||
|
- 1 Счетчик Раунда, 1 Маркер Раунда
|
||||||
|
- 1 Жетон Первого Игрока
|
||||||
|
- 36 Жетонов Ценных Ресурсов (по 12 каждого типа)
|
||||||
|
- 16 Кубов
|
||||||
|
- 12 Боевых Жетонов ([жетон: атака] спереди, [жетон: защита] сзади)
|
||||||
|
- 4 Счетчиков Материалов
|
||||||
|
- 4 Жетона Варп-Штормов
|
||||||
|
- 12 Двусторонних Тайлов Систем
|
||||||
|
- 4 Листа Фракций
|
||||||
|
- 35 Пластиковых Построек (10 Фабрик, 15 Городов,и 10 Бастионов)
|
||||||
|
- 105 Пластиковых Отрядов (27 для Космодесанта, 24 для Эльдаров, 27 для Орков, 27 для Хаоса), и 27 пластиковых подставок (Пластиковые Отряды от КТ визуально отличаются)
|
||||||
|
|
||||||
|
<!-- block: rules.components.material-dials-assembly; page: 3 -->
|
||||||
|
### Сборка счетчиков материалов
|
||||||
|
|
||||||
|
Перед началом игры в *Forbidden Stars* в первый раз, осторожно соберите четыре счетчика материалов, как указано на рисунке ниже. (В редакции от КТ счетчики материалов уже собраны)
|
||||||
|
|
||||||
|
<!-- block: rules.components.ships-assembly; page: 3 -->
|
||||||
|
### Сборка кораблей
|
||||||
|
|
||||||
|
Перед началом игры в *Forbidden Stars*, осторожно вставьте пластиковые подставки в каждую фигурку корабля, как показано на рисунке справа.
|
||||||
|
|
||||||
|
<!-- block: rules.setup; page: 4 -->
|
||||||
|
## Подготовка к игре
|
||||||
|
|
||||||
|
Перед началом игры в *Forbidden Stars*, игроки должны выполнить следующие шаги подготовки к игре:
|
||||||
|
|
||||||
|
<!-- block: rules.setup.choose-factions; page: 4 -->
|
||||||
|
### 1. Выберите Фракции
|
||||||
|
|
||||||
|
Каждый игрок выбирает одну фракцию и берет соответствующие листы фракции, жетоны, карты событий, карты улучшений приказов, боевые карты и отряды этой фракции.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.choose-factions.first-game; page: 4 -->
|
||||||
|
**Если** это ваша первая игра, и игроков меньше четырех, то **исключите** фракции Орков и Эльдар из игры для двух игроков, и фракцию Эльдар из игры для трех игроков.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.starting-components; page: 4 -->
|
||||||
|
### 2. Получите Начальные Компоненты
|
||||||
|
|
||||||
|
Каждый игрок берет все компоненты, перечисленные в графе «*Стартовые Силы*» на обратной стороне листа его фракции. Затем он переворачивает лист своей фракции лицевой стороной вверх и кладет эти компоненты поверх него.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.starting-components.materials; page: 4 -->
|
||||||
|
Наконец, он берет счетчик материалов и устанавливает его на цифру «6». Это значение также указано в поле «*Стартовые Силы*» на обратной стороне листа его фракции.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.first-player; page: 4 -->
|
||||||
|
### 3. Определите Первого Игрока
|
||||||
|
|
||||||
|
Перемешайте по одному маркеру контроля постройки каждого игрока в крышке коробки и вытяните один случайным образом. Этому игроку дается жетон первого игрока.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.game-board; page: 4 -->
|
||||||
|
### 4. Соберите Игровое Поле
|
||||||
|
|
||||||
|
Для первой игры разместите необходимые тайлы систем в центре игровой зоны, как показано на диаграмме «*Расстановка к Первой Игре*» ниже. При игре на менее чем четырех игроков часть игрового поля исключается.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.game-board.place-components; page: 4 -->
|
||||||
|
Затем разместите отряды, постройки, жетоны контроля над постройками, маркеры цели и жетоны варп-штормов на игровом поле, как показано на схеме. Не размещайте жетоны или отряды неиспользуемых фракций. Жетон подкрепления, указанный в «*Стартовых Силах*» орков, не помещается на игровое поле, а остается рядом с его листом фракции, пока не будет использован.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.game-board.first-game-layout; page: 4; kind: caption -->
|
||||||
|
#### Расстановка к первой игре
|
||||||
|
|
||||||
|
*Схема: игровое поле из трех рядов по четыре тайла систем. Верхний ряд — 10A, 1A, 11B, 2B; средний — 5A, 3B, 8A, 6B; нижний — 7A, 12A, 4B, 9A. «2 игрока» — два левых столбца, «3 игрока» — три левых столбца, «4 игрока» — все четыре столбца.*
|
||||||
|
|
||||||
|
<!-- block: rules.setup.game-board.custom-board-note; page: 5 -->
|
||||||
|
**Примечание:** Сыграв одну игру с использованием схемы «*Расстановка к Первой Игре*», игроки могут подготавливаться к игре, используя правила «*Создание Игрового Поля*» на стр. 16. Эти правила позволяют игрокам создавать уникальное игровое поле для каждой игры, выбирая, где размещать тайлы систем, отряды, жетоны целей и варп-штормы.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.decks; page: 5 -->
|
||||||
|
### 5. Подготовьте Колоды Событий, Улучшений и Боевые Колоды
|
||||||
|
|
||||||
|
Каждый игрок перемешивает свои карты событий и кладет их колодой лицевой стороной вниз в своей игровой зоне.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.decks.combat-deck; page: 5 -->
|
||||||
|
Затем каждый игрок находит десять боевых карт с символом своей фракции, напечатанным в верхнем левом углу, и перемешивает их, чтобы сформировать свою боевую колоду.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.decks.faction-symbol-caption; page: 5; kind: caption -->
|
||||||
|
Символ Фракции на Боевой Карте
|
||||||
|
|
||||||
|
<!-- block: rules.setup.decks.upgrade-decks; page: 5 -->
|
||||||
|
Наконец, каждый игрок формирует две колоды улучшений, используя все карты улучшения приказов своей фракции и оставшиеся боевые карты. Эти колоды не нужно тасовать. Игроки кладут их **лицевой стороной вверх** рядом со своим листом фракции, не смешивая их с боевой колодой.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.round-track; page: 5 -->
|
||||||
|
### 6. Подготовьте Счетчик Раундов
|
||||||
|
|
||||||
|
Поместите шкалу раундов рядом с игровым полем и поместите маркер раунда на ячейку «1» на шкале.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.supply; page: 5 -->
|
||||||
|
### 7. Создайте Запас
|
||||||
|
|
||||||
|
Разделите все жетоны подкреплений, жетоны запасов, жетоны кузницы, кубы и пластиковые постройки в кучки и положите их рядом с игровым полем, где все игроки могут их достать.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.ready; page: 5 -->
|
||||||
|
После завершения шагов подготовки к игре компоненты каждого игрока должны находиться в его игровой зоне, как показано ниже. Затем игроки готовы начать первый раунд игры.
|
||||||
|
|
||||||
|
<!-- block: rules.setup.play-area-example; page: 5; kind: caption -->
|
||||||
|
#### Пример игровой зоны
|
||||||
|
|
||||||
|
*Схема: игровая зона игрока — Колода Событий, Жетоны Приказов, Пластиковые Отряды, Счетчик Материалов, Маркеры Контроля Построек, Улучшения Приказов, Боевые Улучшения, Лист Фракции, Боевая Колода.*
|
||||||
|
|
||||||
|
<!-- block: rules.game-round; page: 5 -->
|
||||||
|
## Ход игры
|
||||||
|
|
||||||
|
Игра в *Forbidden Stars* состоит из серии игровых раундов. Каждый игровой раунд состоит из трех фаз, которые играются в следующем порядке:
|
||||||
|
|
||||||
|
1. **Фаза Планирования:** Во время этой фазы игроки по очереди размещают жетоны приказов на игровом поле.
|
||||||
|
2. **Фаза Действий:** Во время этой фазы игроки по очереди разыгрывают жетоны приказов, которые они разместили на игровом поле.
|
||||||
|
3. **Фаза Обновления:** Во время этой фазы игроки собирают цели, материалы, восстанавливают деморализованные отряды, перемещают варп-штормы и разыгрывают карты событий. Затем первый игрок передает жетон первого игрока и продвигает маркер раунда по счетчику раундов.
|
||||||
|
|
||||||
|
<!-- block: rules.game-round.next-round; page: 5 -->
|
||||||
|
После завершения Фазы Обновления игроки начинают новый раунд, начиная с новой Фазы Планирования. Они продолжают разыгрывать игровые раунды до тех пор, пока один из игроков не выиграет игру, собрав достаточное количество маркеров целей (подробно объяснено позже).
|
||||||
|
|
||||||
|
<!-- block: rules.planning; page: 5 -->
|
||||||
|
## Фаза 1: Фаза Планирования
|
||||||
|
|
||||||
|
Во время Фазы Планирования игроки по очереди кладут жетоны приказов лицевой стороной вниз на игровое поле. Игроки раскрывают эти жетоны и применяют их эффекты во время Фазы Действий.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.placing-orders; page: 5 -->
|
||||||
|
Чтобы начать Фазу Планирования, первый игрок кладет один из своих жетонов приказов лицевой стороной вниз на зону для жетонов приказов на **тайле системы**. Затем по часовой стрелке каждый игрок кладет по одному жетону приказа на любой тайл системы. Игроки повторяют этот процесс до тех пор, пока каждый игрок не положит на игровое поле по четыре жетона приказов.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.system-tile-diagram; page: 5; kind: caption -->
|
||||||
|
*Схема тайла системы:* Области Миров; Зона для Жетонов Приказов; Области Пустот.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.order-stacks; page: 5 -->
|
||||||
|
Игроки кладут жетоны приказов **лицевой стороной вниз**, чтобы тип приказа был скрыт от других игроков. Если в системе уже есть один или несколько жетонов приказов, игрок кладет свой жетон приказа поверх стека существующих жетонов приказов. Жетон приказа на вершине стека всегда является жетоном приказа, который был помещен в эту систему последним.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.order-stacks.captions; page: 5; kind: caption -->
|
||||||
|
Жетоны приказов размещаются лицевой стороной вниз на зону для жетонов приказов. Любое количество жетонов приказов могут быть размещены друг на друга формируя стек.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.adjacency; page: 6 -->
|
||||||
|
**Важно:** Игрок не может положить жетон приказа в систему, если у него нет отрядов или построек либо в этой системе, либо в соседних с ней. Каждая система соседствует с системами, с которыми её тайл имеет общее ребро. Если у тайлов двух систем общий только угол, а не ребро, то они не являются соседними.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.order-types; page: 6 -->
|
||||||
|
### Типы жетонов приказов
|
||||||
|
|
||||||
|
Игроки используют жетоны приказов, чтобы выполнять основные действия в *Forbidden Stars*, такие как перемещение отрядов, атака отрядами и производство новых отрядов и построек.
|
||||||
|
|
||||||
|
<!-- block: rules.planning.order-types.list; page: 6 -->
|
||||||
|
Существует четыре типа жетонов приказов. Когда игрок раскрывает жетон приказа, он разыгрывает эффект, соответствующий типу раскрытого жетона.
|
||||||
|
|
||||||
|
- **Развертывание:** Игрок может потратить свои материалы на покупку новых отрядов и построек в системе.
|
||||||
|
- **Планирование:** Игрок может купить улучшение приказа, боевое улучшение или оба. Затем он кладет жетон приказа на верх своей колоды событий, что позволяет ему взять карту события во время Фазы Обновления.
|
||||||
|
- **Доминирование:** Игрок получает ценные ресурсы из каждого дружественного мира в системе. Он также может использовать специальную способность на листе своей фракции.
|
||||||
|
- **Продвижение:** Игрок может переместить свои отряды в систему и начать одну битву.
|
||||||
|
|
||||||
|
Детали для выполнения каждого типа приказов подробно объяснены позже.
|
||||||
|
|
||||||
|
<!-- block: rules.action; page: 6 -->
|
||||||
|
## Фаза 2: Фаза Действий
|
||||||
|
|
||||||
|
Во время Фазы Действий игроки по очереди разыгрывают жетоны приказов, размещенные во время Фазы Планирования. Начиная с первого игрока и далее по часовой стрелке, каждый игрок выбирает один из своих жетонов приказов **сверху любого стека**. Он раскрывает выбранный жетон приказа, разыгрывает его эффект и убирает его с игрового поля. Фаза Действий заканчивается, когда игроки разыграли все жетоны приказов на игровом поле.
|
||||||
|
|
||||||
|
<!-- block: rules.action.resolve-or-event; page: 6 -->
|
||||||
|
Когда активный игрок раскрывает жетон приказа, он решает либо разыграть эффект жетона, либо положить жетон лицевой стороной вверх на верх своей колоды событий. Каждый жетон на колоде событий игрока позволяет ему взять одну карту события во время Фазы Обновления.
|
||||||
|
|
||||||
|
<!-- block: rules.action.no-available-order; page: 6 -->
|
||||||
|
**Примечание:** Если у игрока нет ни одного жетона приказа на вершине какого-либо стека, он обязан пропустить свой ход. Он обязан выполнить приказ во время своего следующего хода, если в этот момент на вершине какого-либо стека будет его приказ.
|
||||||
|
|
||||||
|
<!-- block: rules.units-and-structures; page: 6 -->
|
||||||
|
## Что такое отряды и постройки?
|
||||||
|
|
||||||
|
Наземные отряды и корабли — это типы **отрядов**, представленные пластиковыми фигурками. Игроки перемещают отряды, атакуют ими и размещают их, чтобы контролировать области на игровом поле, что в конечном счете позволяет им собирать свои маркеры целей. **Постройки** предоставляют игрокам боевые усиления (бастионы), позволяют игрокам покупать отряды (фабрики) и повышают командный уровень игрока (города), который необходим для приобретения карт улучшений и более мощных отрядов. Таким образом, для достижения победы игроку необходим тщательный баланс как построек, так и отрядов.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh; page: 6 -->
|
||||||
|
## Фаза 3: Фаза Обновления
|
||||||
|
|
||||||
|
Во время Фазы Обновления каждый игрок собирает материалы и маркеры целей из **дружественных** миров — миров, в которых есть только его отряды и/или постройки. Также во время этой фазы каждый игрок восстанавливает свои деморализованные отряды, чтобы они были готовы к следующему игровому раунду.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.steps; page: 6 -->
|
||||||
|
Во время Фазы Обновления каждый игрок выполняет следующие шаги по порядку:
|
||||||
|
|
||||||
|
1. Соберите Цели
|
||||||
|
2. Соберите Материалы
|
||||||
|
3. Восстановите Деморализованные Отряды
|
||||||
|
4. Возьмите События и Переместите Варп-Штормы
|
||||||
|
5. Конец Раунда
|
||||||
|
|
||||||
|
После завершения этой фазы игроки начинают новый игровой раунд.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.objectives; page: 6 -->
|
||||||
|
### Соберите Цели
|
||||||
|
|
||||||
|
Каждый игрок собирает любые **свои** маркеры **целей**, которые находятся в дружественных мирах. Он помещает маркеры в поле «Маркеры целей» на листе своей фракции, чтобы все игроки могли легко видеть, сколько маркеров он собрал.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.objectives.caption; page: 6; kind: caption -->
|
||||||
|
Игрок за Эльдар собрал маркер цели с дружественного мира.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.objectives.victory; page: 6 -->
|
||||||
|
Игрок выигрывает игру, если он собирает количество маркеров цели, равное количеству игроков в игре (см. «Победа в игре» ниже).
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.materials; page: 6 -->
|
||||||
|
### Соберите Материалы
|
||||||
|
|
||||||
|
У большинства миров есть **производственное значение**, которое обозначается числом на зеленом значке материалов.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.materials.caption; page: 6; kind: caption -->
|
||||||
|
Производственное значение этого мира равно двум
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.materials.gain; page: 7 -->
|
||||||
|
Во время Фазы Обновления каждый игрок получает количество материалов, равное сумме производственных значений всех его дружественных миров. Когда игрок получает материалы, он поворачивает счетчик материалов на соответствующую величину в большую сторону.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.materials.dial-caption; page: 7; kind: caption -->
|
||||||
|
Счетчик материалов установлен на значении «4».
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.rally; page: 7 -->
|
||||||
|
### Восстановите Деморализованные Отряды
|
||||||
|
|
||||||
|
Каждый игрок **восстанавливает** все свои **деморализованные** отряды. Отряды могут стать деморализованными во время битвы и кладутся на бок, чтобы это обозначить. Деморализованный отряд не может перемещаться или быть использованными для срабатывания способностей боевых карт. Он также не может привносить свои кубы или значение своего боевого духа в битву или орбитальный удар. Чтобы показать восстановление отряда, игрок возвращает его в исходное недеморализованное состояние: ставит фигурку ровно.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.rally.caption; page: 7; kind: caption -->
|
||||||
|
Недеморализованный Отряд; Деморализованный Отряд
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.events-and-warp-storms; page: 7 -->
|
||||||
|
### Возьмите События и Переместите Варп-Штормы
|
||||||
|
|
||||||
|
Каждый игрок берет из своей колоды событий количество карт, равное количеству жетонов приказов **на ней**, возвращая жетоны приказов в свой запас неиспользованных жетонов приказов. Жетоны приказов кладутся на верх этой колоды после выполнения Приказа Планирования (подробно объяснено позже).
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.events-and-warp-storms.choose-event; page: 7 -->
|
||||||
|
Затем, начиная с первого игрока и далее по часовой стрелке, каждый игрок выбирает одну карту события из тех, что он только что вытянул. Он перемещает один варп-шторм по игровому полю, следуя значку движения варп-шторма, изображенному на выбранной им карте (см. «Перемещение варп-шторма» справа). Затем он **может** разыграть способность карты события.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.events-and-warp-storms.event-types; page: 7 -->
|
||||||
|
Карты событий бывают двух видов: **тактика** и **план**.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.events-and-warp-storms.event-types.caption; page: 7; kind: caption -->
|
||||||
|
Карта Событий «Тактика»; Карта Событий «План»
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.events-and-warp-storms.tactic-and-scheme; page: 7 -->
|
||||||
|
Разыграв карту события-тактики, игрок замешивает ее обратно в свою колоду событий. Карты событий-планов размещаются лицом вверх рядом с листом фракции игрока, и их эффекты можно использовать в будущем, как описано на карте.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.events-and-warp-storms.reshuffle; page: 7 -->
|
||||||
|
После того, как игрок разыграет свою карту события, он замешивает все невыбранные карты событий из своей руки обратно в свою колоду.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storms; page: 7 -->
|
||||||
|
#### Что такое варп-штормы?
|
||||||
|
|
||||||
|
В *Forbidden Stars* жетоны варп-штормов размещаются на игровом поле вдоль ребер некоторых тайлов систем. Отряды не могут двигаться сквозь варп-штормы. Варп-штормы перемещаются в конце каждого игрового раунда, превращая игровое поле в постоянно меняющуюся и непредсказуемую среду.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storm-movement; page: 7 -->
|
||||||
|
#### Перемещение Варп-Штормов
|
||||||
|
|
||||||
|
Чтобы переместить варп-шторм, игрок выбирает один варп-шторм, **который не перемещали в этой фазе**. Затем он перемещает жетон в одном из двух направлений, указанных значком движения варп-шторма в правой части его карты события.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storm-movement.icon-caption; page: 7; kind: caption -->
|
||||||
|
Иконка Движения Варп-Шторма
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storm-movement.caption; page: 7; kind: caption -->
|
||||||
|
Существует восемь направлений, в которых может двигаться варп-шторм.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storm-movement.no-stacking; page: 7 -->
|
||||||
|
Игроки **не могут** переместить жетон варп-шторма на другой варп-шторм.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storm-movement.must-move; page: 7 -->
|
||||||
|
Когда идёт выбор, какой варп-шторм двигать и направление в котором его двигать, игрок всегда должен выбирать тот результат, при котором произойдёт движение варп-шторма, если это возможно.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.warp-storm-movement.no-events; page: 7 -->
|
||||||
|
**Важно:** Если игрок не брал карты событий, он все равно должен переместить варп-шторм. Для этого он раскрывает верхнюю карту своей колоды событий и перемещает один варп-шторм, используя значок движения варп-шторма на этой карте, как описано выше. Затем он замешивает карту события обратно в свою колоду, не разыгрывая ее способность.
|
||||||
|
|
||||||
|
<!-- block: rules.refresh.end-of-round; page: 8 -->
|
||||||
|
### Конец Раунда
|
||||||
|
|
||||||
|
Первый игрок отдает жетон первого игрока игроку слева от себя. Затем он передвигает маркер раунда на одно деление по счетчику раундов. Если маркер раунда сдвинется с деления «8» счетчика, игра заканчивается, и игрок с наибольшим количеством маркеров целей выигрывает игру.
|
||||||
|
|
||||||
|
<!-- block: rules.victory; page: 8 -->
|
||||||
|
## Победа в игре
|
||||||
|
|
||||||
|
Игрок выигрывает игру, когда он собрал количество своих маркеров целей, **равное количеству игроков в игре**. Например, во время игры для двух игроков игрок выигрывает игру, когда он собирает два своих маркера целей.
|
||||||
|
|
||||||
|
<!-- block: rules.victory.after-round-eight; page: 8 -->
|
||||||
|
Если ни один игрок не выиграл игру к концу восьмого игрового раунда, игра заканчивается, и игрок, собравший больше всего маркеров целей, побеждает в игре.
|
||||||
|
|
||||||
|
<!-- block: rules.victory.objective-markers; page: 8 -->
|
||||||
|
### Что такое маркеры целей?
|
||||||
|
|
||||||
|
Маркеры целей представляют собой важные объекты, людей или места, являющиеся ключевыми для выживания фракции. На обратной стороне каждого маркера цели показано, что тематически представляет этот маркер цели. Хотя у каждого маркера есть уникальное изображение, оно несет лишь тематический характер и **не влияет на игровой процесс**. На обратной стороне листа фракции каждого игрока есть краткое описание историй, связанных с его целями.
|
||||||
|
|
||||||
|
<!-- block: rules.victory.objective-markers.description-caption; page: 8; kind: caption -->
|
||||||
|
Описание Маркера Цели: «Лорд Системы: Лорд Халовар присягнул на верность Империуму. Теперь он просит помощи в избавлении от тьмы.»
|
||||||
|
|
||||||
|
<!-- block: rules.victory.objective-markers.face-up; page: 8 -->
|
||||||
|
Маркеры целей **всегда остаются лежать лицевой стороной вверх**, чтобы игроки могли видеть символ фракции. Это важно, потому что игрок не может собирать маркеры целей, принадлежащие другим игрокам. Однако игроки могут размещать отряды в мирах, содержащих вражеские маркеры целей, чтобы помешать своим противникам добиться победы.
|
||||||
|
|
||||||
|
<!-- block: rules.victory.objective-markers.face-caption; page: 8; kind: caption -->
|
||||||
|
Лицевая Сторона Маркера Цели
|
||||||
|
|
||||||
|
<!-- block: rules.orders; page: 8 -->
|
||||||
|
## Приказы подробно
|
||||||
|
|
||||||
|
Каждая фракция имеет по два жетона каждого из четырех типов приказов. Когда игрок выполняет приказ во время Фазы Действий, тайл системы, на котором был размещен приказ, становится **активной системой**. В этом разделе описывается, как игроки выполняют приказы каждого из четырех типов.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy; page: 8 -->
|
||||||
|
### Приказ Развертывания
|
||||||
|
|
||||||
|
Игроки используют Приказ Развертывания, чтобы размещать новые отряды и постройки на игровом поле. Чтобы выполнить Приказ Развертывания, активный игрок совершает следующие два шага **в указанном порядке**:
|
||||||
|
|
||||||
|
1. **Покупка Отрядов**: Если у игрока есть фабрика в активной системе, он может купить отряды и разместить их в любых дружественных или неконтролируемых (не содержащих никаких отрядов или построек) областях в активной системе.
|
||||||
|
2. **Покупка Построек**: Игрок может купить **одну** постройку и разместить ее в любом дружественном мире в активной системе, где еще нет постройки.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.token-caption; page: 8; kind: caption -->
|
||||||
|
Жетон Приказа Развертывания
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-units; page: 8 -->
|
||||||
|
#### Покупка Отрядов
|
||||||
|
|
||||||
|
Игрок покупает отряд, тратя количество материала, равное стоимости в материалах этого отряда, указанной на его листе фракции. Чтобы потратить материал, игрок поворачивает счетчик материалов на соответствующую величину в меньшую сторону. Затем он размещает отряд в любую **дружественную или неконтролируемую** область в активной системе.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-units.forge-tokens; page: 8 -->
|
||||||
|
Некоторые отряды стоят жетон кузницы в дополнение к их стоимости в материалах. Чтобы потратить жетон кузницы, игрок берет жетон кузницы из своей игровой зоны и возвращает его в запас.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-units.cost-caption; page: 8; kind: caption -->
|
||||||
|
*Схема строки отряда на листе фракции:* Материальная Стоимость Отряда; Требование Жетона Кузницы.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-units.placement; page: 8 -->
|
||||||
|
Игроки могут размещать наземные отряды только в мирах, а корабли — в пустотах. В дополнение к этим основным ограничениям игрок обязан соблюдать ограничения на командный уровень.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-units.deployment-limit; page: 8 -->
|
||||||
|
Максимальное количество отрядов, которое игрок может приобрести в рамках одного Приказа Развертывания, определяется его **лимитом развертывания**. Его лимит развертывания равен вместимости мира активной системы, где расположена фабрика. Вместимость равна количеству черепов на знамени этого мира.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-units.capacity-caption; page: 8; kind: caption -->
|
||||||
|
Вместимость отрядов этого мира равна двум.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.command-level; page: 9 -->
|
||||||
|
#### Командный уровень
|
||||||
|
|
||||||
|
У каждого игрока есть **командный уровень**, который указывает, какие отряды и улучшения он может купить. Командный уровень игрока равен количеству городов, которые он контролирует.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.command-level.requirements; page: 9 -->
|
||||||
|
У каждого отряда и карты улучшения приказа или боевой карты есть требование к командному уровню. Игрок может приобретать отряды и улучшения только с требованиями, равными его командному уровню или меньшими, чем его командный уровень.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.command-level.caption; page: 9; kind: caption -->
|
||||||
|
*Схема строки отряда на листе фракции:* Требуемый Командный Уровень Отряда.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-structures; page: 9 -->
|
||||||
|
#### Покупка Построек
|
||||||
|
|
||||||
|
Игрок покупает постройку (город, бастион или фабрику), потратив количество материалов, равное стоимости в материалах этой постройки, указанной на его листе фракции. Затем он размещает пластиковую постройку на любой дружественный мир в активной системе поверх одного из своих **маркеров контроля постройки**. Маркер под каждой постройкой указывает, кому принадлежит эта постройка.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-structures.one-per-world; page: 9 -->
|
||||||
|
**Важно:** Игрок не может разместить постройку в мире, в котором уже есть постройка.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.deploy.purchase-structures.caption; page: 9; kind: caption -->
|
||||||
|
Эта постройка контролируется игроком фракции Эльдар.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate; page: 9 -->
|
||||||
|
### Приказ Доминирования
|
||||||
|
|
||||||
|
Игроки используют Приказы Доминирования, чтобы получать ценные ресурсы с миров и использовать свои особые способности фракции. Чтобы выполнить Приказ Доминирования, активный игрок выполняет следующие шаги по порядку:
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.token-caption; page: 9; kind: caption -->
|
||||||
|
Жетон Приказа Доминирования
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.gain-resources; page: 9 -->
|
||||||
|
#### 1. Получение ценных ресурсов
|
||||||
|
|
||||||
|
Игрок получает ценные ресурсы, предоставленные каждым дружественным миром в активной системе. Ценные ресурсы отображаются в правом углу знамени мира.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.gain-resources.take-tokens; page: 9 -->
|
||||||
|
Игрок берет соответствующие жетоны из запаса и кладет их в свою игровую зону. Если мир предоставляет несколько ценных ресурсов, игрок берет их все.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.faction-ability; page: 9 -->
|
||||||
|
#### 2. Использование специальной способности фракции
|
||||||
|
|
||||||
|
Игрок может использовать специальную способность, указанную на листе его фракции.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources; page: 9 -->
|
||||||
|
#### Ценные ресурсы
|
||||||
|
|
||||||
|
Игроки могут получить ценные ресурсы с дружественных миров, выполнив Приказ Доминирования. Каждый ценный ресурс имеет уникальный эффект, как описано ниже:
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.forge; page: 9 -->
|
||||||
|
##### Жетоны Кузницы
|
||||||
|
|
||||||
|
Некоторые отряды требуют, чтобы игрок потратил жетон кузницы, чтобы купить их. Это отображается на листе фракции ниже стоимости соответствующего отряда в материалах.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.forge.command-level; page: 9 -->
|
||||||
|
В качестве альтернативы игрок может потратить жетон кузницы при покупке отряда, чтобы снизить требование к его командному уровню на **один**. Это позволяет ему купить отряд, который при нормальных условиях не может быть куплен (см. «Командный уровень» слева).
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.forge.caption; page: 9; kind: caption -->
|
||||||
|
Этот мир предоставляет жетон кузницы.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.supply; page: 9 -->
|
||||||
|
##### Жетоны Запасов
|
||||||
|
|
||||||
|
При покупке отряда или постройки игрок может потратить жетон запасов, чтобы снизить стоимость в материалах этого отряда или постройки на два.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.supply.caption; page: 9; kind: caption -->
|
||||||
|
Этот мир предоставляет жетон запасов.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.reinforcement; page: 9 -->
|
||||||
|
##### Жетоны Подкрепления
|
||||||
|
|
||||||
|
Во время шага вызова подкреплений во время битвы атакующий, а затем защищающийся могут разместить свои жетоны подкреплений в оспариваемую область.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.reinforcement.unit; page: 9 -->
|
||||||
|
Жетон подкрепления остается на время битвы и считается наземным отрядом или кораблем соответствующей фракции нулевого командного уровня. Находясь в области, он соблюдает все правила и ограничения, применимые к отрядам.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.reinforcement.example; page: 9 -->
|
||||||
|
Например, каждый жетон подкрепления, который игрок Ультрамаринов размещает в мире, рассматривается как отряд Разведчиков.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.reinforcement.caption; page: 9; kind: caption -->
|
||||||
|
Этот мир предоставляет жетон подкрепления.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.prosperity; page: 9 -->
|
||||||
|
##### Процветание
|
||||||
|
|
||||||
|
Когда игрок получает этот ценный ресурс, он вместо этого получает один жетон ресурса по своему выбору.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.dominate.resources.prosperity.caption; page: 9; kind: caption -->
|
||||||
|
Этот мир предоставляет один жетон ценного ресурса любого типа
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize; page: 10 -->
|
||||||
|
### Приказ Планирования
|
||||||
|
|
||||||
|
Чтобы выполнить Приказ Планирования у игрока должен быть **отряд или постройка в активной системе**. Он может просмотреть все карты в своих колодах улучшений. Затем он может купить одно улучшение приказа и/или одно боевое улучшение, если он соответствует ограничениям командного уровня каждой карты (см. «Командный уровень» на стр. 9). После выполнения этого приказа он кладет жетон приказа **на верх своей колоды событий**, что позволит ему взять карту события во время Фазы Обновления.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.token-caption; page: 10; kind: caption -->
|
||||||
|
Жетон Приказа Планирования
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.upgrade-cost; page: 10 -->
|
||||||
|
Чтобы купить улучшение, игрок должен потратить количество материалов, равное стоимости в материалах, указанной в левом верхнем углу карты улучшения приказа или боевой колоды. Чтобы потратить материалы, игрок поворачивает счетчик материалов на соответствующую величину в меньшую сторону.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.upgrade-cost.caption; page: 10; kind: caption -->
|
||||||
|
*Схема карты улучшения:* Требуемый Командный Уровень Улучшения; Материальная Стоимость Улучшения.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.upgrade-types; page: 10 -->
|
||||||
|
Существует два типа улучшений: **улучшения приказов** и **боевые улучшения**, а именно:
|
||||||
|
|
||||||
|
- **Улучшения Приказов:** Улучшения приказов повышают функциональность жетонов приказов. После покупки улучшения приказа игрок помещает его в свою игровую зону рядом со своим листом фракции. Каждое улучшение приказа соответствует одному из четырех приказов (Развертывание, Планирование, Доминирование, Продвижение) и дает игроку преимущество при выполнении соответствующего приказа.
|
||||||
|
- **Боевые Улучшения:** Боевые улучшения — это более мощные боевые карты, которые игрок добавляет в свою боевую колоду.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.upgrade-types.caption; page: 10; kind: caption -->
|
||||||
|
Карта Улучшения Приказов («Крестовый поход»); Карта Боевого Улучшения («Держать строй»)
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.combat-upgrades; page: 10 -->
|
||||||
|
#### Покупка боевых улучшений
|
||||||
|
|
||||||
|
Игрок покупает карты боевых улучшений парами. Когда игрок покупает одну карту боевого улучшения, он получает **обе копии карты**.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.combat-upgrades.swap; page: 10 -->
|
||||||
|
Когда игрок покупает пару боевых карт, он должен убрать две копии любой другой карты из своей боевой колоды и поместить их обе в свою колоду боевых улучшений. Затем он добавляет обе копии купленного улучшения в свою боевую колоду и перемешивает колоду.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.strategize.combat-upgrades.deck-size; page: 10 -->
|
||||||
|
Боевая колода всегда содержит две копии пяти карт, что в сумме дает десять карт.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance; page: 10 -->
|
||||||
|
### Приказ Продвижения
|
||||||
|
|
||||||
|
Игроки используют Приказы Продвижения для перемещения отрядов и начала битв. Чтобы выполнить Приказ Продвижения, игрок перемещает отряды **в активную систему**. После перемещения отрядов игрок разрешает битву, если есть **оспариваемая область** — область, в которой есть как дружественные, так и вражеские отряды или постройки.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.token-caption; page: 10; kind: caption -->
|
||||||
|
Жетон Приказа Продвижения
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.steps; page: 10 -->
|
||||||
|
Чтобы выполнить Приказ Продвижения, активный игрок совершает следующие шаги по порядку:
|
||||||
|
|
||||||
|
1. **Перемещение Кораблей:** Активный игрок может переместить свои корабли в активной системе и из одной соседней системы в любые пустоты в активной системе.
|
||||||
|
2. **Перемещение Наземных Отрядов:** Активный игрок может переместить свои наземные отряды в активной системе и из одной соседней системы в любые миры в активной системе. Если он перемещал корабли из соседней системы, он не может перемещать наземные отряды из другой соседней системы.
|
||||||
|
3. **Разрешение Битвы:** Активный игрок разрешает битву, если есть оспариваемая область (см. «Битва» на стр. 12). Если оспариваемой области нет, он может вместо этого провести орбитальный удар (см. «Орбитальный удар» на стр. 11).
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.limits; page: 10 -->
|
||||||
|
**Важно:** Отряды **не могут двигаться через варп-штормы**, и игрок может создать **максимум одну** оспариваемую область при выполнении Приказа Продвижения. Игрок может переместить любое количество отрядов Приказом Продвижения, но **максимум пять отрядов** могут закончить свое перемещение в каждой области.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.ship-movement; page: 10 -->
|
||||||
|
#### Перемещение Кораблей
|
||||||
|
|
||||||
|
При перемещении корабля игрок может переместить корабль из пустоты, которую он в данный момент занимает, в любую пустоту в активной системе, даже если эти две области не являются соседними.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.ground-movement; page: 10 -->
|
||||||
|
#### Перемещение Наземных Отрядов
|
||||||
|
|
||||||
|
При перемещении наземного отряда игрок может переместить отряд из мира, в котором он находится в данный момент, в любой мир в активной системе, соединенной **путем**. Путь представляет собой ряд соседних (не по диагонали) дружественных областей. Путь может состоять из миров, пустот или их сочетаний (см. «Пример Приказа Продвижения» на стр. 11).
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.ground-movement.adjacent-worlds; page: 10 -->
|
||||||
|
Наземные отряды могут перемещаться из одного мира в соседний без использования корабля. Тематически они используют невоенные транспортные корабли или другие простые формы передвижения, не представленные в игре. Количество отрядов, которые игрок может перемещать через дружественную область, не ограничено.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.capacity; page: 10 -->
|
||||||
|
#### Вместимость Отрядов
|
||||||
|
|
||||||
|
Каждая область имеет **вместимость**. Вместимость пустоты равна трем, а вместимость каждого мира равна количеству значков черепа, представленных на знамени мира. Вместимость области указывает **максимальное количество отрядов**, которые могут существовать в области после полного выполнения приказа.
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.capacity.caption; page: 10; kind: caption -->
|
||||||
|
Вместимость Отрядов Мира
|
||||||
|
|
||||||
|
<!-- block: rules.orders.advance.example; page: 11; kind: example -->
|
||||||
|
#### Пример Приказа Продвижения
|
||||||
|
|
||||||
|
1. Настала очередь игрока за Ультрамаринов разыгрывать приказ, поэтому он раскрывает один из своих Приказов Продвижения переворачивая его лицевой стороной вверх.
|
||||||
|
2. Сначала он должен переместить корабли. У него нет кораблей в активной системе, поэтому он перемещает Ударный Крейсер из соседней системы в пустоту в активной системе.
|
||||||
|
3. Затем игрок решает переместить некоторые из своих наземных отрядов из соседней системы в активную систему. Теперь, когда у него есть корабль в пустоте, он может перемещать через нее наземные отряды. Он перемещает своих Космодесантников через пустоту в мир в правом верхнем углу активной системы.
|
||||||
|
4. Затем игрок перемещает своего Разведчика через пустоту в мир в левом нижнем углу активной области.
|
||||||
|
5. Наконец, игрок решает переместить своего Разведчика, который уже был в активной системе, в соседний мир в правом нижнем углу.
|
||||||
|
|
||||||
|
<!-- block: rules.orbital-strike; page: 11 -->
|
||||||
|
## Орбитальный удар
|
||||||
|
|
||||||
|
После выполнения Приказа Продвижения, если нет оспариваемых областей (и, следовательно, битва не начиналась), активный игрок может выполнить орбитальный удар.
|
||||||
|
|
||||||
|
<!-- block: rules.orbital-strike.procedure; page: 11 -->
|
||||||
|
Чтобы выполнить орбитальный удар, игрок выбирает вражеский мир в активной системе. Затем он выбирает одну пустоту в активной системе, соседнюю этому миру. Он бросает количество кубиков, равное общей боевой мощи всех его кораблей в этой пустоте.
|
||||||
|
|
||||||
|
<!-- block: rules.orbital-strike.damage; page: 11 -->
|
||||||
|
Вражеские отряды в выбранном мире получают урон, равный количеству выпавших значков атаки ([куб: атака]). Боевая мощь и получение урона подробно объясняются позже.
|
||||||
|
|
||||||
|
<!-- block: rules.orbital-strike.bastion; page: 11 -->
|
||||||
|
Игрок не может выполнить орбитальный удар по миру, содержащему бастион. Бастионы описаны позже.
|
||||||
|
|
||||||
|
<!-- block: rules.battle; page: 12 -->
|
||||||
|
## Битва
|
||||||
|
|
||||||
|
После того, как игрок перемещает отряды с помощью Приказа Продвижения, он должен разыграть битву, если у него есть отряды в области, содержащей как дружественные, так и вражеские отряды или постройки (т. е. оспариваемой области). Во время битвы игрок, выполняющий Приказ Продвижения, является **атакующим**, а другой игрок - **защищающимся**.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.overview; page: 12 -->
|
||||||
|
В битвах в *Forbidden Stars* используется комбинация кубов и карт. Игроки начинают битву, бросая кубы в зависимости от боевой мощи своих отрядов. Затем они разыгрывают боевые карты, чтобы активировать специальные способности и получить боевые знаки, кубы и боевые жетоны. Игрок с наибольшим значением боевого духа ([мораль]) в конце битвы становится победителем, а его противник должен отступить.
|
||||||
|
|
||||||
|
Битва состоит из следующих шагов:
|
||||||
|
|
||||||
|
<!-- block: rules.battle.preparation; page: 12 -->
|
||||||
|
### 1. Подготовка
|
||||||
|
|
||||||
|
Игроки готовятся к битве, выполняя следующие подшаги:
|
||||||
|
|
||||||
|
<!-- block: rules.battle.preparation.roll-dice; page: 12 -->
|
||||||
|
#### а. Бросьте Кубы
|
||||||
|
|
||||||
|
И нападающий, и защищающийся одновременно бросают количество кубов, равное суммарной **боевой мощи** всех его недеморализованных отрядов в бою. Боевая мощь отрядов игрока указана на его листе фракции. Например, если игрок за Ультрамаринов имеет в бою два Ленд Рейдера, он бросает шесть кубов.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.preparation.roll-dice.caption; page: 12; kind: caption -->
|
||||||
|
Боевая Мощь Отряда На Листе Фракции
|
||||||
|
|
||||||
|
<!-- block: rules.battle.preparation.roll-dice.group; page: 12 -->
|
||||||
|
Каждый игрок распределяет свои брошенные кубы по группам с одинаковыми значками, чтобы и он, и его противник могли легко оценить результаты.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.preparation.draw-cards; page: 12 -->
|
||||||
|
#### б. Возьмите Боевые Карты
|
||||||
|
|
||||||
|
Каждый игрок берет пять боевых карт с верха своей боевой колоды.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.preparation.reinforcements; page: 12 -->
|
||||||
|
#### в. Шаг Вызова Подкреплений
|
||||||
|
|
||||||
|
Сначала у атакующего, а затем у защищающегося есть возможность разместить несколько своих жетонов подкреплений рядом со своими отрядами в оспариваемой области. Количество жетонов подкреплений, которое может разместить игрок, равно количеству его отрядов в битве (см. «Ценные ресурсы» на стр. 9).
|
||||||
|
|
||||||
|
<!-- block: rules.battle.round; page: 12 -->
|
||||||
|
### 2. Раунд битвы
|
||||||
|
|
||||||
|
Игроки играют до трех раундов битвы, разыгрывая боевые карты со своих рук. Во время **каждого раунда битвы** игроки выполняют следующие подшаги:
|
||||||
|
|
||||||
|
<!-- block: rules.battle.round.choose-card; page: 12 -->
|
||||||
|
#### а. Выберите Боевую Карту
|
||||||
|
|
||||||
|
Атакующий и защищающийся одновременно выбирают по одной боевой карте с руки и кладут выбранную карту лицевой стороной вниз в игровую зону.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.round.resolve-card; page: 12 -->
|
||||||
|
#### б. Разыграйте Боевую Карту
|
||||||
|
|
||||||
|
Атакующий раскрывает и разыгрывает свою боевую карту. Затем защитник раскрывает и разыгрывает свою боевую карту, как описано на стр. 13.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.round.damage; page: 12 -->
|
||||||
|
#### в. Шаг Нанесения Повреждений
|
||||||
|
|
||||||
|
Начиная с атакующего, оба игрока должны получить урон, равный общему количеству значков атаки его противника ([атака]) с кубов, боевых жетонов и открытых боевых карт. Каждый значок защиты ([защита]), который есть у игрока (с кубов, боевых жетонов и его открытых боевых карт), уменьшает количество получаемого урона на один. Правила получения урона объясняются на стр. 13.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.round.damage.caption; page: 12; kind: caption -->
|
||||||
|
Игрок Ультрамаринов в общей сложности имеет три знака атаки ([атака]) и четыре защиты ([защита]).
|
||||||
|
|
||||||
|
<!-- block: rules.battle.round.damage.sole-survivor; page: 12 -->
|
||||||
|
После того, как оба игрока получили урон, если только у одного игрока остались отряды и/или бастионы в области, он побеждает в битве и переходит к подэтапу «Захватите постройки», как описано ниже.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolution; page: 12 -->
|
||||||
|
### 3. Разрешение
|
||||||
|
|
||||||
|
После завершения третьего раунда битвы игроки заканчивают битву, выполнив следующие подшаги:
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolution.determine-winner; page: 12 -->
|
||||||
|
#### а. Определите Победителя
|
||||||
|
|
||||||
|
Игрок с наибольшим суммарным значением боевого духа побеждает в бою (защитник побеждает при ничье), а вражеские отряды должны отступить (объясняется позже).
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolution.determine-winner.total-morale; page: 12 -->
|
||||||
|
Игрок определяет свой суммарный боевой дух, складывая количество значков своего боевого духа ([мораль]) на своих кубах и боевых картах, лежащих лицом вверх, а также значения боевого духа и на листе своей фракции, которые соответствуют каждому из его бастионов и недеморализованных отрядов в битве.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolution.determine-winner.caption; page: 12; kind: caption -->
|
||||||
|
Боевой Дух Отряда На Листе Фракции
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolution.capture-structures; page: 12 -->
|
||||||
|
#### б. Захватите Постройки
|
||||||
|
|
||||||
|
Если атакующий побеждает в битве, он получает контроль над всеми постройками в мире, убирая маркеры контроля построек противника и заменяя их своими собственными.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolution.cleanup; page: 12 -->
|
||||||
|
#### в. Очистка
|
||||||
|
|
||||||
|
Каждый игрок сбрасывает все жетоны подкреплений, которые он использовал, в запас и замешивает все свои боевые карты обратно в свою боевую колоду.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolving-cards; page: 13 -->
|
||||||
|
### Розыгрыш Боевых Карт
|
||||||
|
|
||||||
|
Все боевые карты имеют одну или две ячейки боевых способностей. Разыгрывая боевую карту, игрок сначала применяет все общие способности (зеленая ячейка), а затем применяет все способности отрядов (коричневая ячейка). Он применяет каждую способность в том порядке, в котором она указана, сверху вниз.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolving-cards.unit-abilities; page: 13 -->
|
||||||
|
Способностям отрядов всегда предшествует хотя бы одно имя отряда. Чтобы использовать способность отрядов, игрок должен иметь хотя бы один из перечисленных отрядов в бою, и этот отряд должен быть недеморализованным.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.resolving-cards.caption; page: 13; kind: caption -->
|
||||||
|
*Схема боевой карты:* Общая Способность — «Получите 2 [жетон: защита].»; Способность Отрядов — «Бастион/Космодесантник/Ударный Крейсер: Переверните до 2 ваших кубов на [куб: защита].»
|
||||||
|
|
||||||
|
Чтобы воспользоваться способностью отрядов, у игрока должен быть хотя бы один Бастион или недеморализованный Космодесантник или недеморализованный Ударный Крейсер в битве.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.card-icons; page: 13 -->
|
||||||
|
### Значки Боевых Карт
|
||||||
|
|
||||||
|
Большинство боевых карт содержат один или несколько боевых значков ([атака], [защита], [мораль]) в левой части карты. Эти значки действуют так же, как значки на кубах, и сохраняются до конца битвы.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.card-icons.caption; page: 13; kind: caption -->
|
||||||
|
Значки на Боевой Карте («Не показывать страха»)
|
||||||
|
|
||||||
|
<!-- block: rules.battle.dice; page: 13 -->
|
||||||
|
### Боевые Кубы
|
||||||
|
|
||||||
|
Когда игрок получает [куб: атака], [куб: защита] или [куб: мораль] от способности, он берет один кубик из запаса неиспользованных кубов и кладет его рядом с другими кубами указанным значком вверх. Когда игрок теряет куб, он берет один из своих кубов с соответствующим значком и возвращает его в запас.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.dice.roll-extra; page: 13 -->
|
||||||
|
Если игровой эффект позволяет игроку получить [куб], он берет куб из запаса, бросает его и кладет его вместе с другими своими кубами.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.dice.limit; page: 13 -->
|
||||||
|
**Важно:** Атакующий и защищающийся ограничены **восьмью кубами** во время битвы.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.tokens; page: 13 -->
|
||||||
|
### Боевые Жетоны
|
||||||
|
|
||||||
|
Некоторые способности боевых карт предоставляют игрокам боевые жетоны, представленные значками боевых жетонов ([жетон: атака] и [жетон: защита]). Когда игрок получает [жетон: атака] или [жетон: защита], он берет боевой жетон из резерва и кладет его соответствующей стороной вверх рядом со своими кубами. При расчете атаки или защиты во время битвы боевые жетоны игрока добавляются к его окончательному значению. Все боевые жетоны являются **временными** и возвращаются в запас в конце раунда битвы, в котором они были получены.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.tokens.caption; page: 13; kind: caption -->
|
||||||
|
Жетоны Сражения
|
||||||
|
|
||||||
|
<!-- block: rules.battle.taking-damage; page: 13 -->
|
||||||
|
### Получение Урона
|
||||||
|
|
||||||
|
Когда игрок получает урон, он обязан выбрать один дружественный отряд в оспариваемой области, который получит урон. Этот отряд должен быть недеморализованным, кроме ситуаций, когда все отряды деморализованы. Если урон равен **показателю здоровья** выбранного отряда или превышает его, этот отряд **уничтожается** и удаляется с игрового поля.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.taking-damage.caption; page: 13; kind: caption -->
|
||||||
|
Показатель Здоровья Отряда на Листе Фракции
|
||||||
|
|
||||||
|
<!-- block: rules.battle.taking-damage.excess; page: 13 -->
|
||||||
|
Если урон ниже показателя здоровья выбранного отряда, этот отряд становится деморализованным. Если урон **превышает** показатель здоровья выбранного отряда, любой урон, превышающий показатель здоровья отряда, должен быть нанесен другому дружественному отряду. Игрок повторяет этот процесс до тех пор, пока либо не останется урона, либо не останется больше отрядов.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.taking-damage.orbital-strike; page: 13 -->
|
||||||
|
**Важно:** При получении урона во время орбитального удара любые неуничтоженные отряды **не становятся деморализованными**.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.taking-damage.example; page: 13; kind: example -->
|
||||||
|
*Пример:* У игрока за Ультрамаринов в битве два Разведчика. В течение первого раунда битвы он должен получить три урона. Он выбирает одного из своих Разведчиков, который первым получит урон. Он сравнивает свои два здоровья с тремя единицами урона; поскольку показатель здоровья Разведчика равно или меньше урона, он уничтожается. Затем игрок назначает один оставшийся урон другому своему Разведчику. Этот отряд не уничтожается, а становится деморализованным, а урон отбрасывается, потому что показатель здоровья отряда превышает урон.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.routed-units; page: 13 -->
|
||||||
|
### Деморализованные Отряды
|
||||||
|
|
||||||
|
Отряды могут стать **деморализованными** из-за способностей боевых карт, отступления или получения урона, который меньше их показателей здоровья. При деморализации отряда пластиковая модель кладется на бок. Если отряд представлен жетоном подкрепления, этот жетон переворачивается на деморализованную сторону.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.routed-units.effects; page: 13 -->
|
||||||
|
Деморализованные отряды не добавляют значение своего боевого духа ([мораль]) при определении победителя боя и не могут удовлетворять требованиям отрядов на боевой карте. Кроме того, игрок **не может назначать урон** деморализованному отряду, если у него есть один или несколько недеморализованных отрядов или бастионов в области. Во время Фазы Обновления все деморализованные отряды восстанавливаются: пластиковые модели поднимаются вертикально.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.bastions; page: 13 -->
|
||||||
|
### Бастионы
|
||||||
|
|
||||||
|
Бастионы не являются отрядами, но они добавляют кубы и свои значения боевого духа во время битвы. Кроме того, бастионы могут быть выбраны для получения урона. Если бастион получает урон, равный показателю его здоровья, он удаляется с игрового поля и возвращается в запас. Бастионы не могут стать деморализованными.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.example-part-1; page: 14; kind: example -->
|
||||||
|
### Пример битвы часть I
|
||||||
|
|
||||||
|
1. Игрок за Ультрамаринов (синий) выполняет Приказ Продвижения. После перемещения своих отрядов у него есть два отряда в области с двумя эльдарскими отрядами. Игроки за Ультрамаринов и Эльдар теперь должны разрешить битву.
|
||||||
|
2. Игроки сверяются с листами своих фракций, чтобы определить, сколько кубов дают их отряды. Оба игрока одновременно бросают свои кубы.
|
||||||
|
|
||||||
|
Затем каждый игрок берет пять карт из своей боевой колоды и держит эти карты скрытыми друг от друга.
|
||||||
|
|
||||||
|
**Первый раунд битвы:** Каждый игрок выбирает боевую карту из своей руки и кладет ее лицом вниз перед собой. Затем игроки выполняют следующие шаги:
|
||||||
|
|
||||||
|
3. Игрок за Ультрамаринов является атакующим, поэтому он первым раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему два боевых жетона защиты.
|
||||||
|
4. Затем он применяет способность отрядов в коричневой ячейке. У него есть отряд Космодесантников, необходимый для применения этой способности, поэтому он решает перевернуть один из своих [куб: атака] кубов в [куб: защита] куб.
|
||||||
|
5. Игрок за Эльдар раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему один куб. Он бросает куб и кладет его рядом с остальными кубами.
|
||||||
|
6. Затем игрок за Эльдар применяет способность отрядов в коричневой ячейке. У него есть Аспектный Воин, и он решает потратить [куб: мораль] куб, чтобы вынудить своего противника выбрать и деморализовать один свой отряд. Игрок за Ультрамаринов решает деморализовать одного из своих Разведчиков.
|
||||||
|
|
||||||
|
В качестве последнего шага этого раунда битвы игроки должны получить урон. Каждый игрок подсчитывает количество имеющихся у него боевых значков на своих кубах, своих боевых картах и своих боевых жетонах.
|
||||||
|
|
||||||
|
Ни один из игроков не имеет больше значков атаки ([атака]), чем значков защиты его противника ([защита]), поэтому ни один из игроков не получает урон.
|
||||||
|
|
||||||
|
Игроки сбрасывают все боевые жетоны, затем проводят следующий раунд битвы(см. «Пример битвы, часть II» на стр. 15).
|
||||||
|
|
||||||
|
<!-- block: rules.battle.example-part-2; page: 15; kind: example -->
|
||||||
|
### Пример битвы часть II
|
||||||
|
|
||||||
|
**Второй Раунд Битвы:** Каждый игрок выбирает боевую карту из своей руки и кладет ее лицом вниз перед собой. Затем игроки выполняют следующие шаги:
|
||||||
|
|
||||||
|
7. Игрок за Ультрамаринов раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему один куб. Он бросает куб и кладет его рядом с остальными своими кубами.
|
||||||
|
8. Затем он применяет способность отрядов в коричневой ячейке. У него есть отряд Космодесантников, необходимый для применения этой способности, поэтому он выбирает восстановить своего Разведчика.
|
||||||
|
9. Затем игрок за Эльдар раскрывает и разыгрывает свою карту. Сначала он применяет общую способность в зеленой ячейке, что дает ему два боевых жетона атаки.
|
||||||
|
10. Теперь игрок за Эльдар может применить способность в коричневой ячейке, но он решает это не делать.
|
||||||
|
11. Каждый игрок подсчитывает количество значков атаки ([атака]) на его кубах, боевых картах и боевых жетонах.
|
||||||
|
|
||||||
|
Игрок за Эльдар — единственный игрок, у которого больше значков атаки ([атака]), чем значков защиты ([защита]) его противника. У него шесть значков атаки, а у его противника — два значка защиты, поэтому его противник получает четыре урона.
|
||||||
|
12. Игрок за Ультрамаринов решает сначала распределить урон на Разведчика. У него всего два здоровья и он уничтожен. Остальные два урона распределяются на другой его отряд, у которого три здоровья. Этого урона недостаточно для его уничтожения, поэтому отряд становится деморализованным.
|
||||||
|
|
||||||
|
Игроки сбрасывают все боевые жетоны. Поскольку у обоих игроков остались отряды, они разыгрывают еще один раунд битвы. Если у обоих игроков есть хотя бы по одному выжившему отряду, игрок с наибольшим суммарным значением боевого духа в конце этого раунда битвы побеждает в битве, а его противник должен отступить всеми своими отрядами.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.retreat; page: 15 -->
|
||||||
|
### Отступление
|
||||||
|
|
||||||
|
Отряды должны отступить, когда они проигрывают битву. Кроме того, некоторые способности боевых карт могут заставить отряды отступить. После отступления отряды становятся деморализованными.
|
||||||
|
Атакующие и защищающиеся отряды следуют разным правилам отступления, а именно:
|
||||||
|
|
||||||
|
<!-- block: rules.battle.retreat.attacker; page: 15 -->
|
||||||
|
#### Атакующий отступает
|
||||||
|
|
||||||
|
Когда атакующий отступает, он должен переместить все свои оставшиеся отряды из оспариваемой области в другую область — корабли должны отступить в пустоту, а наземные отряды должны отступить в мир по легальному пути перемещения.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.retreat.attacker.same-area; page: 15 -->
|
||||||
|
Все отступающие отряды обязаны переместиться в одну и ту же область, и это должна быть область, из которой изначально переместился хотя бы один из отрядов в битве.
|
||||||
|
|
||||||
|
<!-- block: rules.battle.retreat.defender; page: 15 -->
|
||||||
|
#### Защищающийся отступает
|
||||||
|
|
||||||
|
Когда защищающийся отступает, он должен переместить все свои оставшиеся отряды из оспариваемой области в дружественную или неконтролируемую область в активной системе или соседней системе — корабли должны отступить в пустоту, а наземные отряды должны отступить в мир по легальному пути перемещения. Все отступающие отряды должны переместиться в одну и ту же область. Если защищающийся может отступить как в дружественную, так и в неконтролируемую область, он обязан отступить в дружественную область.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.stop; page: 16 -->
|
||||||
|
## Стоп!
|
||||||
|
|
||||||
|
Теперь вы знаете все правила, необходимые для вашей первой игры. Поиграв со схемой *«Расстановка к Первой Игре»*, вы готовы изучить дополнительные правила на этой странице.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building; page: 16 -->
|
||||||
|
## Создание игрового поля
|
||||||
|
|
||||||
|
После игры с использованием игрового поля, представленного на схеме *«Расстановка к Первой Игре»*, игроки готовы играть, создав уникальное игровое поле во время подготовки к игре самостоятельно.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.steps; page: 16 -->
|
||||||
|
Сборка игрового поля — это стратегическое упражнение, в котором игроки выбирают, где размещать тайлы системы и где размещать свои первоначальные силы. На шаге подготовки к игре «Сборка игрового поля» игроки выполняют следующие действия:
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.distribute-tiles; page: 16 -->
|
||||||
|
### 1. Распределите Тайлы Систем
|
||||||
|
|
||||||
|
Каждый игрок получает тайл системы, на котором есть значок его фракции. Затем первый игрок берет все тайлы систем, на которых нет значков фракций, перемешивает их под столом и раздает по два каждому игроку.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.distribute-objectives; page: 16 -->
|
||||||
|
### 2. Распределите маркеры целей
|
||||||
|
|
||||||
|
Каждый игрок отдает по два своих маркера целей каждому другому игроку и возвращает оставшиеся маркеры в коробку с игрой.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.assemble; page: 16 -->
|
||||||
|
### 3. Соберите игровое поле
|
||||||
|
|
||||||
|
Начиная с первого игрока и далее по **часовой стрелке**, каждый игрок размещает один тайл системы в игровой зоне следующим образом:
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.assemble.place-tile; page: 16 -->
|
||||||
|
#### а. Разместите Тайл Системы
|
||||||
|
|
||||||
|
Игрок выбирает один из своих тайлов системы и кладет его рядом хотя бы с одним другим тайлом системы (см. «Пример размещения тайла» справа).
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.assemble.place-forces; page: 16 -->
|
||||||
|
#### б. Разместите Отряды и Постройки
|
||||||
|
|
||||||
|
Игрок может разместить любое количество компонентов со своего листа фракции (в ячейке «Стартовые силы») на тайл, который он только что разместил.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.assemble.place-objectives; page: 16 -->
|
||||||
|
#### в. Разместите Маркеры Целей
|
||||||
|
|
||||||
|
Игрок **обязан** разместить один из маркеров целей противника **на каждую ячейку для маркеров целей** на только что размещенном тайле системы, соблюдая следующие ограничения:
|
||||||
|
|
||||||
|
- Игрок не может размещать два маркера одной и той же фракции на одном тайле системы.
|
||||||
|
- Игрок не может разместить маркер цели, принадлежащий одной фракции, если у него больше маркеров цели, принадлежащих другой фракции.
|
||||||
|
- После того, как он разместил все тайлы, полученные на шаге 2, он больше не размещает маркеров целей.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.assemble.objective-zone-caption; page: 16; kind: caption -->
|
||||||
|
Зона Размещения Маркера Целей
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.assemble.repeat; page: 16 -->
|
||||||
|
Игроки повторяют этот процесс до тех пор, пока каждый игрок не поместит все свои тайлы системы и все свои компоненты, перечисленные в поле «Стартовые силы» на листе своей фракции.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.warp-storms; page: 16 -->
|
||||||
|
### 4. Разместите Варп-Штормы
|
||||||
|
|
||||||
|
Начиная с игрока, **разместившего последнюю систему**, и далее **против часовой стрелки**, каждый игрок размещает один варп-шторм вдоль любого ребра любого тайла системы; это может быть внешнее ребро - край игрового поля.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.tile-placement; page: 16 -->
|
||||||
|
### Размещение Тайлов Системы
|
||||||
|
|
||||||
|
При размещении тайла системы игрок может разместить либо тайл своей фракции, либо любой другой тайл. Он может положить тайл любой стороной вверх и в любой ориентации; однако после того, как первый игрок поместит свой первый тайл системы, все остальные тайлы должны быть размещены **по соседству** с другим тайлом.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.tile-placement.max-size; page: 16 -->
|
||||||
|
Игрок **не может размещать тайл** таким образом, чтобы он нарушал максимальный размер игрового поля. Этот размер варьируется в зависимости от количества игроков:
|
||||||
|
|
||||||
|
- **Два Игрока:** Три тайла на два тайла
|
||||||
|
- **Три Игрока:** Три тайла на три тайла
|
||||||
|
- **Четыре Игрока:** Три тайла на четыре тайла
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.tile-placement.orientation; page: 16 -->
|
||||||
|
Ориентация игрового поля не определяется до тех пор, пока максимальное количество тайлов не будет размещено в одном ряду или столбце. Например, игра для четырех игроков может быть либо шириной в четыре тайла, либо высотой в четыре тайла. Это определяется, как только четвертый тайл размещается в строке или столбце.
|
||||||
|
|
||||||
|
<!-- block: rules.board-building.tile-placement.example; page: 16; kind: example -->
|
||||||
|
#### Пример размещения тайла
|
||||||
|
|
||||||
|
Настала очередь игрока за Эльдар размещать тайл во время игры вчетвером. Он решает разместить тайл своей фракции.
|
||||||
|
|
||||||
|
Он может разместить тайл своей фракции в любом из восьми возможных мест, обведенных зеленым.
|
||||||
|
Он не может разместить свой тайл ни на одной из трех ячеек на концах, потому что это нарушит максимальный размер игрового поля, отведенный для игры вчетвером, который составляет три тайла на четыре тайла.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const qk = {
|
export const qk = {
|
||||||
me: ["me"] as const,
|
me: ["me"] as const,
|
||||||
adminMe: ["adminMe"] as const,
|
adminMe: ["adminMe"] as const,
|
||||||
@@ -30,10 +32,11 @@ export const qk = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
|
||||||
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
|
||||||
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
|
||||||
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
|
||||||
|
* написан строкой.
|
||||||
*/
|
*/
|
||||||
export const matchAffectedKeys = [
|
export const matchAffectedKeys = [
|
||||||
qk.home,
|
qk.home,
|
||||||
@@ -42,3 +45,15 @@ export const matchAffectedKeys = [
|
|||||||
["userMatches"],
|
["userMatches"],
|
||||||
["publicProfile"],
|
["publicProfile"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
|
||||||
|
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
|
||||||
|
* открытые на экране запросы, остальные лишь помечаются устаревшими.
|
||||||
|
*/
|
||||||
|
export function invalidateRatingViews(qc: QueryClient) {
|
||||||
|
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||||
|
qc.invalidateQueries({
|
||||||
|
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
||||||
|
|
||||||
export interface PlacePlayer {
|
export interface PlacePlayer {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -241,19 +242,11 @@ export function PlaceEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
||||||
const raw = e.target.value.trim();
|
|
||||||
const num = Number(raw);
|
|
||||||
onCount?.(
|
|
||||||
userId,
|
|
||||||
field,
|
|
||||||
raw === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num))),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */
|
||||||
|
export const MAX_COUNT = 99;
|
||||||
|
|
||||||
|
/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */
|
||||||
|
export function parseCount(raw: string): number | null {
|
||||||
|
const text = raw.trim();
|
||||||
|
const num = Number(text);
|
||||||
|
if (text === "" || !Number.isFinite(num)) return null;
|
||||||
|
return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num)));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api, unwrap } from "../api/client";
|
import { api, unwrap } from "../api/client";
|
||||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||||
import type {
|
import type {
|
||||||
FactionRead,
|
FactionRead,
|
||||||
MatchCreate,
|
MatchCreate,
|
||||||
@@ -83,8 +83,7 @@ export function useFinishMatch() {
|
|||||||
onSuccess: (m) => {
|
onSuccess: (m) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
invalidateRatingViews(qc);
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
|
|||||||
onSuccess: (m) => {
|
onSuccess: (m) => {
|
||||||
qc.setQueryData(qk.match(m.id), m);
|
qc.setQueryData(qk.match(m.id), m);
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
// Места изменились — значит изменились рейтинги, топ, истории и профили.
|
||||||
// Места изменились — значит изменились лидерборд, история и профили.
|
invalidateRatingViews(qc);
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||||
import { useMe } from "./auth";
|
|
||||||
|
|
||||||
interface ServerEvent {
|
interface ServerEvent {
|
||||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
|
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
|
||||||
|
type:
|
||||||
|
| "match"
|
||||||
|
| "match_draft"
|
||||||
|
| "ratings"
|
||||||
|
| "group"
|
||||||
|
| "invitations"
|
||||||
|
| "notifications"
|
||||||
|
| "announcements";
|
||||||
match_id?: number;
|
match_id?: number;
|
||||||
group_id?: number;
|
group_id?: number;
|
||||||
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
|
||||||
participant_ids?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,11 +24,6 @@ interface ServerEvent {
|
|||||||
*/
|
*/
|
||||||
export function useServerEvents(enabled: boolean) {
|
export function useServerEvents(enabled: boolean) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data: me } = useMe();
|
|
||||||
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
|
||||||
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
|
||||||
const myId = useRef<number | null>(null);
|
|
||||||
myId.current = me?.id ?? null;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
@@ -50,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
|
||||||
}
|
|
||||||
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
|
||||||
if (ev.participant_ids) {
|
|
||||||
// Личные витрины — только у игравших: иначе каждая партия в группе
|
|
||||||
// заставляла бы всех остальных перезапрашивать свою историю.
|
|
||||||
for (const pid of ev.participant_ids) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
|
|
||||||
}
|
|
||||||
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Событие от бэкенда без списка участников (вкладка открыта до обновления
|
|
||||||
// сервера) — ведём себя как раньше, широко.
|
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
}
|
}
|
||||||
|
// Рейтинг общий и считается по всей истории: партия двигает топ, историю
|
||||||
|
// и профили всех, кто играл после неё, и страницы других групп.
|
||||||
|
invalidateRatingViews(qc);
|
||||||
|
} else if (ev.type === "ratings") {
|
||||||
|
invalidateRatingViews(qc);
|
||||||
} else if (ev.type === "group") {
|
} else if (ev.type === "group") {
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function HelpPage() {
|
|||||||
<p className="small" style={{ marginTop: 10 }}>
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
||||||
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
||||||
делят последнее место.
|
делят последнее место, но между собой не сравниваются: в этой партии все они
|
||||||
|
проиграли, так что пара двух выбывших рейтинг не двигает.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ export function HelpPage() {
|
|||||||
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
||||||
</p>
|
</p>
|
||||||
<p className="small" style={{ marginBottom: 0 }}>
|
<p className="small" style={{ marginBottom: 0 }}>
|
||||||
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
|
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
|
||||||
|
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
|
||||||
|
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
|||||||
|
|
||||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
||||||
|
|
||||||
|
// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие
|
||||||
|
// места), затем выбывшие с общим последним местом.
|
||||||
|
const placeRows = (blocks: number[][], eliminated: number[]) => {
|
||||||
|
let place = 1;
|
||||||
|
const survivors = blocks.flatMap((ids) => {
|
||||||
|
const rows = ids.map((uid) => ({ uid, place, eliminated: false }));
|
||||||
|
place += ids.length;
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))];
|
||||||
|
};
|
||||||
|
|
||||||
export function MatchDetailPage() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
@@ -193,21 +205,14 @@ export function MatchDetailPage() {
|
|||||||
|
|
||||||
const outcomeSeats = (): OutcomeSeat[] => {
|
const outcomeSeats = (): OutcomeSeat[] => {
|
||||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
||||||
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
|
return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
userId: uid,
|
userId: uid,
|
||||||
nickname: byId.get(uid)?.nickname ?? "",
|
nickname: byId.get(uid)?.nickname ?? "",
|
||||||
place,
|
place,
|
||||||
eliminated,
|
eliminated,
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
||||||
});
|
}));
|
||||||
let place = 1;
|
|
||||||
const rows = finishBlocks.flatMap((ids) => {
|
|
||||||
const out = ids.map((uid) => seat(uid, place, false));
|
|
||||||
place += ids.length;
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
return [...rows, ...elim.map((uid) => seat(uid, place, true))];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const warnings = () =>
|
const warnings = () =>
|
||||||
@@ -218,35 +223,16 @@ export function MatchDetailPage() {
|
|||||||
maxRounds: match.max_rounds,
|
maxRounds: match.max_rounds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
|
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
|
||||||
const resultRows = () => {
|
const resultRows = () =>
|
||||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
const countsFor = (uid: number, eliminated: boolean) => ({
|
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
|
||||||
// У выбывшего миров нет — сервер сам запишет 0.
|
|
||||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
|
||||||
});
|
|
||||||
let place = 1;
|
|
||||||
const survivors = finishBlocks.flatMap((ids) => {
|
|
||||||
const rows = ids.map((uid) => ({
|
|
||||||
user_id: uid,
|
|
||||||
place,
|
|
||||||
eliminated: false,
|
|
||||||
comment: commentOf(uid),
|
|
||||||
...countsFor(uid, false),
|
|
||||||
}));
|
|
||||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
|
||||||
return rows;
|
|
||||||
});
|
|
||||||
const eliminated = elim.map((uid) => ({
|
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
place: null,
|
place: eliminated ? null : place,
|
||||||
eliminated: true,
|
eliminated,
|
||||||
comment: commentOf(uid),
|
comment: (finishComments[uid] ?? "").trim() || null,
|
||||||
...countsFor(uid, true),
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
|
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||||
}));
|
}));
|
||||||
return [...survivors, ...eliminated];
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitFinish = async () => {
|
const submitFinish = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia";
|
|||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { Switch } from "../../components/Switch";
|
import { Switch } from "../../components/Switch";
|
||||||
import { finishWarnings } from "../../domain/finishWarnings";
|
import { finishWarnings } from "../../domain/finishWarnings";
|
||||||
|
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
|
||||||
import {
|
import {
|
||||||
LAST_STANDING,
|
LAST_STANDING,
|
||||||
reasonForSurvivors,
|
reasonForSurvivors,
|
||||||
@@ -34,12 +35,6 @@ interface Row {
|
|||||||
worlds: number | null;
|
worlds: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пустое поле — «не указано» (null); иначе целое 0..99.
|
|
||||||
const parseCount = (raw: string): number | null => {
|
|
||||||
const num = Number(raw.trim());
|
|
||||||
return raw.trim() === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||||
const { data: factions } = useAdminFactions();
|
const { data: factions } = useAdminFactions();
|
||||||
@@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="цели"
|
placeholder="цели"
|
||||||
title="Цели на конец партии"
|
title="Цели на конец партии"
|
||||||
value={r.objectives ?? ""}
|
value={r.objectives ?? ""}
|
||||||
@@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="миры"
|
placeholder="миры"
|
||||||
title="Миры на конец партии"
|
title="Миры на конец партии"
|
||||||
disabled={r.eliminated}
|
disabled={r.eliminated}
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ switch ($appEnv) {
|
|||||||
if ($localPublic -eq "vps") {
|
if ($localPublic -eq "vps") {
|
||||||
Start-VpsTunnel 5173
|
Start-VpsTunnel 5173
|
||||||
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
||||||
|
Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow
|
||||||
|
Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow
|
||||||
|
Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"production" {
|
"production" {
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ case "$app_env" in
|
|||||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||||
echo " Фронт: http://127.0.0.1:5173"
|
echo " Фронт: http://127.0.0.1:5173"
|
||||||
[ "$local_public" = "vps" ] && start_tunnel 5173
|
if [ "$local_public" = "vps" ]; then
|
||||||
|
start_tunnel 5173
|
||||||
|
echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля,"
|
||||||
|
echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger."
|
||||||
|
echo " Не держите в dev-базе копию прод-данных."
|
||||||
|
fi
|
||||||
( cd "$root/frontend" && npm run dev )
|
( cd "$root/frontend" && npm run dev )
|
||||||
;;
|
;;
|
||||||
production)
|
production)
|
||||||
|
|||||||
Reference in New Issue
Block a user