From 95b69e449adf392f8c9b3924c67e52261bfe7e77 Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Tue, 22 Sep 2026 23:23:50 -0400 Subject: [PATCH 01/19] Plan V3: accounts, public deployment and practice recommendations Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 242 ++++++++++++++++++++++++++++++++++-------------- README.md | 8 +- README.zh-CN.md | 8 +- ROADMAP.md | 38 +++++--- 4 files changed, 202 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7926303..4061349 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,124 @@ V2 delivered the structured, multi-boss Sekiro analytics application: * four required CI jobs: backend, frontend, integration, docker * local deployment with Docker Compose -Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) is deferred until after V3. +Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) moved into V3 as Milestone 4. -The V2 sections below remain the reference for how the existing code was built. V3 scope has not been written into this file yet. +Released as `v2.0.0`. + +--- + +# Current Development Focus: V3 — Personalized Practice Coach + +V3 answers: + +> **What should I practice next?** + +V3 scope is exactly three capabilities: + +```text +User accounts ++ +Public deployment ++ +Rule-based practice recommendations +``` + +Long-term tracking and practice goals are NOT part of V3. Long-term progression is already covered by V2's progression chart, recent vs. all-time comparisons and Sekiro dashboard. Practice goals were considered and dropped. + +## Precedence + +The V2 sections later in this file still describe how the existing code is built, and their engineering rules still apply: SQLAlchemy, reviewed Alembic migrations, the service layer, honest analytics, the lightweight attempt form, source-traceable boss data, and testing against PostgreSQL. + +Where a V2 section conflicts with this V3 section, this section wins. In particular, V2's non-goals of "user accounts", "authentication", "personalized multi-user histories" and "recommendation engines" are lifted only as far as the V3 milestones below require. + +## V3 Product Principles + +* Recording an attempt stays as quick as in V2: result, phase reached, failure move / `Other` / `Not Sure`, optional notes. Accounts must not add fields to that form. +* Boss, phase and move data stays global and shared by all users. Only attempts belong to users. +* Every analytics and recommendation query is scoped to the current user. A user must never see or write another user's attempts. +* Recommendations are derived only from the user's own attempt records. There is no in-game telemetry. +* Every recommendation shows the evidence behind it, as counts from the user's attempts. +* Do not present recommendations as success rates, skill scores or difficulty ratings. The data cannot support them. + +## V3 Non-Goals + +Do NOT introduce the following in V3 unless explicitly requested: + +* OAuth or third-party login +* password reset emails or other email infrastructure +* user roles, admin panels, or teams +* social features: sharing, following, leaderboards +* practice goals +* machine learning or LLM-generated recommendations +* gameplay video, computer vision, or combat telemetry (V4) +* Black Myth: Wukong or multi-game UI (V5) +* Redis, message queues, Kubernetes, or microservices without a demonstrated need + +## V3 Milestones + +Implement V3 incrementally. Each milestone should leave the project in a working state. + +### Milestone 1 — Accounts and Authentication + +* add a `users` table through an Alembic migration +* hash passwords with argon2 +* register, log in, log out +* authenticate with an httpOnly session cookie. The frontend and backend are same-origin through nginx, so JWTs are not needed. +* add a login / register page in the frontend +* boss data remains browsable without logging in; recording attempts and viewing analytics require login + +### Milestone 2 — User-Owned Attempts + +* add `attempts.user_id` as a foreign key to `users`, through a reviewed Alembic migration +* migrate existing attempts to the owner's account explicitly. Do not silently delete or orphan them. +* record the current user on every new attempt +* read attempt history for the current user only + +### Milestone 3 — Per-User Analytics and Isolation + +* scope boss analytics, progression and the Sekiro dashboard to the current user +* add tests proving users cannot read or write each other's attempts, at both the API and service level +* keep the V2 analytics definitions unchanged apart from the user scope + +### Milestone 4 — Public Deployment + +Moved from V2 Milestone 10. It comes after Milestones 1–3 because accounts are what make a public instance safe. See "Public Deployment" below for the work involved. + +### Milestone 5 — Practice Recommendations + +* rule-based and explainable, derived only from the user's attempt records +* candidate signals include: + * the recent bottleneck phase + * the most common recorded failure move among attempts that reached that move's phases + * undefeated bosses the user has practiced recently +* every recommendation includes its evidence, for example "6 of your last 10 attempts ended in Phase 2; 4 of those were Floating Passage" +* failure counts for a move only count attempts that reached a phase containing that move, so dying earlier cannot make a move look "improved" +* the exact rules are decided with the user before implementation + +### Milestone 6 — Integration Testing, CI and Release + +* an end-to-end test: register → record attempts → analytics → recommendations +* extend CI to cover authentication and user isolation +* verify the production deployment workflow +* release `v3.0.0` + +## V3 Success Criteria + +V3 is complete when a user can: + +```text +Open the public URL + ↓ +Register / Log In + ↓ +Record Attempts + ↓ +See Only Their Own History and Analytics + ↓ +Get Practice Recommendations with Evidence +``` + +Once these criteria are met, merge `dev` into `main`, release `v3.0.0`, and stop V3 development. Do not delay the release by adding V4 features. --- @@ -125,7 +240,7 @@ V2 should preserve that functionality while adding: * recent vs historical comparisons * overall Sekiro-level analytics * stronger integration testing -* a reliable local deployment with Docker Compose (public hosting is deferred until after V3) +* a reliable local deployment with Docker Compose (public hosting moved into V3 Milestone 4) --- @@ -268,7 +383,7 @@ These belong to V3–V5 or should only be introduced when justified by an actual * Docker Compose, run locally -V2 has no public hosting and no staging or production environments. Public deployment is deferred until after V3; see Milestone 10. +V2 has no public hosting and no staging or production environments. Public deployment moved into V3 Milestone 4; see "Public Deployment (V3 Milestone 4)". --- @@ -1354,24 +1469,27 @@ v2.0.0 (run locally with Docker Compose) --- -# Public Deployment (Deferred Until After V3) +# Public Deployment (V3 Milestone 4) -V2 runs locally only. A stable public URL is deferred until after V3, because: +V2 ran locally only. Public deployment was moved into V3 because: -* the application currently has a single user, who can run it locally * V2 has no authentication, so a public instance would let anyone record attempts into the only attempt history -* V3 adds user accounts, which solves that problem directly -* V4 gameplay analysis (video storage, computer vision) will likely need different infrastructure anyway, so a hosting setup chosen now may not carry forward +* V3 Milestones 1–3 add user accounts and per-user data, which solves that problem directly -For temporary remote access, such as from a phone away from home, a Cloudflare Tunnel to the local instance is acceptable. +Until Milestone 4 is done, a Cloudflare Tunnel to the local instance is acceptable for temporary remote access, such as from a phone away from home. -When public deployment is picked up, the provider-independent work is: +The work involved: -* a production compose configuration that does not publish the PostgreSQL or backend ports -* HTTPS -* scheduled `pg_dump` backups of attempt data +* a production compose configuration that exposes only the reverse proxy, not the PostgreSQL or backend ports +* HTTPS, for example with Caddy's automatic certificates +* secrets supplied through environment variables; secure, SameSite session cookies +* rate limiting on login and registration +* scheduled `pg_dump` backups of attempt data, with a restore tested at least once * deployment from `main` through GitHub Actions -* setup documentation +* choosing a host and a domain. Free tiers such as Oracle Cloud Always Free may reclaim idle instances; a small paid VPS is more predictable. +* setup documentation in the README + +V4 gameplay analysis (video storage, computer vision) will likely need different infrastructure, so keep this deployment simple rather than building for V4. --- @@ -1644,11 +1762,11 @@ A broken persistence or analytics change should normally be detected before merg --- -## Milestone 10 — Stable Deployment (Deferred Until After V3) +## Milestone 10 — Stable Deployment (Moved to V3 Milestone 4) ### Decision -Public deployment is deferred until after V3. See "Public Deployment (Deferred Until After V3)" for the reasons and the work involved. +Public deployment moved into V3 as Milestone 4. See "Public Deployment (V3 Milestone 4)" for the reasons and the work involved. ### What V2 Still Includes @@ -1691,7 +1809,7 @@ Recommended sequence: 13. CI Hardening -14. Stable Deployment (deferred until after V3, see Milestone 10) +14. Stable Deployment (moved to V3 Milestone 4, see Milestone 10) 15. Release v2.0.0 ``` @@ -1752,21 +1870,7 @@ Do not delay the V2 release by introducing V3 features. ## V3 — Personalized Practice Coach -Core question: - -> What should I practice next? - -Potential additions: - -* authentication -* user accounts -* user-owned attempts -* long-term player profiles -* practice goals -* personalized recommendations -* public deployment with a stable URL (moved from V2 Milestone 10) - -These are NOT V2 requirements. +Current development focus. See "Current Development Focus: V3" near the top of this file. --- @@ -1785,7 +1889,7 @@ Potential additions: * automatic move detection * true per-move success rates -These are NOT V2 requirements. +These are NOT V3 requirements. --- @@ -1802,7 +1906,7 @@ Potential additions: * game-specific combat mechanics * cross-game analytics -These are NOT V2 requirements. +These are NOT V3 requirements. --- @@ -1810,57 +1914,53 @@ These are NOT V2 requirements. When working in this repository: -1. Treat V1 as completed. -2. Treat V2 as the current active development scope. -3. Preserve the lightweight manual attempt-recording workflow. +1. Treat V1 and V2 as completed. +2. Treat V3 as the current active development scope, limited to accounts, public deployment and rule-based practice recommendations. +3. Preserve the lightweight manual attempt-recording workflow; accounts must not add fields to it. 4. Do not add detailed manual combat telemetry. 5. Continue supporting `Other` and `Not Sure`. 6. Prefer structured move IDs over free-text move names. -7. Keep analytics derived from attempt data where practical. +7. Keep analytics and recommendations derived from attempt data. 8. Do not invent success rates without occurrence denominators. -9. Migrate persistence from JSON to PostgreSQL cleanly. -10. Use SQLAlchemy for relational persistence. -11. Use Alembic for schema migrations. -12. Review generated migrations before applying them. -13. Preserve frontend API contracts where practical. -14. Do not silently rewrite working frontend behavior during the database migration. -15. Keep database access outside route handlers where practical. -16. Keep analytics logic outside route handlers. -17. Avoid unnecessary repository/factory/framework abstractions. -18. Expand boss coverage only after the PostgreSQL-backed V1 workflow is stable. +9. Show the evidence behind every recommendation. +10. Scope every attempt, analytics and recommendation query to the current user. +11. Keep boss, phase and move data global. +12. Never store plain-text passwords; hash them with argon2. +13. Use SQLAlchemy for relational persistence. +14. Use Alembic for schema migrations, and review generated migrations before applying them. +15. Migrate existing attempts to a user explicitly; never silently drop them. +16. Preserve frontend API contracts where practical; change frontend types and clients together when a contract must change. +17. Keep database access and analytics logic outside route handlers. +18. Avoid unnecessary repository/factory/framework abstractions. 19. Do not create large amounts of low-quality boss data. -20. Do not invent Sekiro mechanics when uncertain. -21. Keep game knowledge traceable to sources where practical. -22. Do not add authentication in V2. -23. Do not add video analysis in V2. -24. Do not add Black Myth: Wukong in V2. -25. Do not add Redis, Kafka, Kubernetes, or microservices without a concrete requirement. -26. Prefer incremental changes over large rewrites. -27. Preserve working functionality after every milestone. -28. Add tests for meaningful behavior, not arbitrary coverage targets. -29. Keep derived analytics semantically honest. -30. Stop V2 once the defined V2 success criteria are complete. +20. Do not invent Sekiro mechanics when uncertain, and keep game knowledge traceable to sources. +21. Do not commit secrets, production credentials or private database URLs. +22. Do not add OAuth, email infrastructure, roles, social features or practice goals in V3. +23. Do not add video analysis (V4) or Black Myth: Wukong (V5). +24. Do not add Redis, Kafka, Kubernetes, or microservices without a concrete requirement. +25. Prefer incremental changes over large rewrites, and preserve working functionality after every milestone. +26. Add tests for meaningful behavior, including user isolation, not arbitrary coverage targets. +27. Stop V3 once the defined V3 success criteria are complete. --- # Current Milestone Rule -Before implementing a new feature, identify which V2 milestone it belongs to. +Before implementing a new feature, identify which V3 milestone it belongs to. If the feature does not clearly belong to: ```text -PostgreSQL Migration -Multi-Boss Support -Richer Boss Data -Progression Analytics -Sekiro Dashboard -Testing / CI -Local Docker Setup +Accounts and Authentication +User-Owned Attempts +Per-User Analytics and Isolation +Public Deployment +Practice Recommendations +Testing / CI / Release ``` -check whether it actually belongs to V3, V4, or V5 before adding it. +check whether it actually belongs to V4 or V5, or was deliberately dropped from V3, before adding it. -The goal of V2 is not to maximize feature count. +The goal of V3 is not to maximize feature count. -The goal is to turn the completed V1 MVP into a structured, maintainable, multi-boss Sekiro analytics application and then release `v2.0.0`. \ No newline at end of file +The goal is to let each player keep their own practice history on a public instance and get honest, evidence-backed practice recommendations, and then release `v3.0.0`. diff --git a/README.md b/README.md index 8213a6e..f4d293c 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ V2 runs locally with Docker Compose. #### Deferred or Skipped -* **Public deployment** is deferred until after V3: there is currently a single user, and without accounts a public instance would let anyone record attempts. V3 adds accounts, which removes that problem. +* **Public deployment** moved into V3: there is currently a single user, and without accounts a public instance would let anyone record attempts. V3 adds accounts, which removes that problem. * **Boss search and filtering** was skipped, because 8 bosses fit comfortably on one page. --- @@ -208,11 +208,11 @@ richer progression analytics ↓ V3 -User profiles +User accounts + -long-term personalized practice tracking +per-user attempt histories + -practice recommendations +evidence-based practice recommendations + public deployment diff --git a/README.zh-CN.md b/README.zh-CN.md index 06f6c52..147d2f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -181,7 +181,7 @@ V2 使用 Docker Compose 在本地运行。 #### 推迟或跳过的内容 -* **公网部署**推迟到 V3 之后:目前只有一个用户,而且在没有账号系统的情况下,公开的实例会让任何人都能新增攻略记录。V3 会加入账号系统,这个问题也就随之解决。 +* **公网部署**移到了 V3:目前只有一个用户,而且在没有账号系统的情况下,公开的实例会让任何人都能新增攻略记录。V3 会加入账号系统,这个问题也就随之解决。 * **Boss 搜索和筛选**被跳过了,因为 8 个 Boss 在一个页面上就能放得下。 --- @@ -208,11 +208,11 @@ PostgreSQL ↓ V3 -用户档案 +用户账号 + -长期的个性化练习追踪 +按用户区分的攻略记录 + -练习建议 +附带依据的练习建议 + 公网部署 diff --git a/ROADMAP.md b/ROADMAP.md index 1bb7006..fa79074 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,43 +68,51 @@ V2 remains focused specifically on **Sekiro**. Not included in V2: * boss search and filtering, which was skipped because 8 bosses fit on one page -* public deployment, which is deferred until after V3: with a single user and no accounts, a public instance would let anyone record attempts +* public deployment, which moved into V3: with a single user and no accounts, a public instance would let anyone record attempts ### Completion target (met) A stable multi-boss Sekiro analytics application backed by PostgreSQL, running locally, that can show both boss-specific and game-level player progression. +Released as `v2.0.0`. + --- -## V3 — Personalized Practice Coach +## V3 — Personalized Practice Coach 🚧 **Core question:** **What should I practice next?** -V3 introduces individual player identity and personalized training. +V3 introduces individual player identity, public deployment and practice recommendations. -Main goals: +Milestones: -* user accounts and authentication -* user-specific attempt histories -* long-term practice tracking -* practice goals -* personalized weakness identification -* practice recommendations -* public deployment with a stable URL, once accounts make it safe to expose +1. **Accounts and authentication:** register, log in, log out, with hashed passwords and session cookies +2. **User-owned attempts:** every attempt belongs to a user; existing attempts move to the owner's account +3. **Per-user analytics:** boss analytics, progression and the Sekiro dashboard show only the user's own data +4. **Public deployment:** a stable URL with HTTPS, backups and deployment from `main` +5. **Practice recommendations:** rule-based suggestions derived from the user's own attempts, each showing its evidence +6. **Integration testing, CI and release** Example output: ```text id="7v1njg" Recommended Practice -1. Floating Passage -2. Phase 3 Lightning +1. Genichiro Ashina — Phase 2 + 6 of your last 10 attempts ended in Phase 2. -Next Goal: -Reach Phase 3 for 3 consecutive attempts +2. Floating Passage + 4 of those 6 Phase 2 failures were recorded as Floating Passage. ``` +Recommendations are based on recorded failures, not success rates, because the app does not know how often each move actually occurred. + +Not included in V3: + +* long-term practice tracking as a separate feature, since V2's progression analytics already cover it +* practice goals + V3 turns the application from an analytics tracker into a personalized practice assistant. --- From 8cd121768b2d97a4739eb058c81124aad3a233c2 Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 20:25:13 -0400 Subject: [PATCH 02/19] Add accounts and authentication (V3 Milestone 1) - users and user_sessions tables (reviewed Alembic migration) - argon2 password hashing; server-side sessions in an httpOnly, SameSite=Lax cookie, stored only as a SHA-256 hash - register / login / logout / me endpoints; attempts and analytics now require login, boss data stays public - frontend: nav bar, login and register pages with a show-password toggle, welcome page with the boss list for visitors, login prompt on boss pages - tests for auth, access control, the auth UI, and the integration workflow; CI smoke test logs in through nginx Attempts are still shared by all logged-in users until Milestones 2-3. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 +- README.md | 2 +- README.zh-CN.md | 2 +- .../980c7a879ddb_add_users_and_sessions.py | 53 +++++++ backend/app/auth.py | 44 ++++++ backend/app/db/models.py | 29 ++++ backend/app/main.py | 3 +- backend/app/models/auth.py | 33 +++++ backend/app/routers/attempts.py | 7 +- backend/app/routers/auth.py | 50 +++++++ backend/app/routers/sekiro.py | 3 +- backend/app/services/auth_service.py | 90 ++++++++++++ backend/requirements.txt | 1 + backend/tests/conftest.py | 11 +- backend/tests/db/test_schema.py | 2 +- backend/tests/test_auth.py | 130 ++++++++++++++++++ .../integration-tests/attemptWorkflow.test.ts | 57 ++++++++ frontend/src/App.tsx | 19 ++- frontend/src/api/auth.test.ts | 55 ++++++++ frontend/src/api/auth.ts | 24 ++++ frontend/src/api/client.ts | 6 + frontend/src/auth/AuthProvider.test.tsx | 67 +++++++++ frontend/src/auth/AuthProvider.tsx | 51 +++++++ frontend/src/auth/authContext.ts | 19 +++ frontend/src/auth/testAuth.tsx | 20 +++ frontend/src/components/BossList.tsx | 49 +++++++ frontend/src/components/NavBar.tsx | 42 ++++++ frontend/src/components/PasswordInput.tsx | 60 ++++++++ frontend/src/index.css | 124 +++++++++++++++++ frontend/src/main.tsx | 5 +- frontend/src/pages/AuthPage.test.tsx | 92 +++++++++++++ frontend/src/pages/AuthPage.tsx | 106 ++++++++++++++ frontend/src/pages/BossDashboardPage.test.tsx | 31 ++++- frontend/src/pages/BossDashboardPage.tsx | 105 +++++++++----- frontend/src/pages/BossSelectionPage.test.tsx | 2 +- frontend/src/pages/BossSelectionPage.tsx | 50 +------ frontend/src/pages/HomePage.test.tsx | 53 +++++++ frontend/src/pages/HomePage.tsx | 18 +++ frontend/src/pages/WelcomePage.tsx | 25 ++++ frontend/src/types/index.ts | 10 ++ 40 files changed, 1453 insertions(+), 107 deletions(-) create mode 100644 backend/alembic/versions/980c7a879ddb_add_users_and_sessions.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/models/auth.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/tests/test_auth.py create mode 100644 frontend/src/api/auth.test.ts create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/auth/AuthProvider.test.tsx create mode 100644 frontend/src/auth/AuthProvider.tsx create mode 100644 frontend/src/auth/authContext.ts create mode 100644 frontend/src/auth/testAuth.tsx create mode 100644 frontend/src/components/BossList.tsx create mode 100644 frontend/src/components/NavBar.tsx create mode 100644 frontend/src/components/PasswordInput.tsx create mode 100644 frontend/src/pages/AuthPage.test.tsx create mode 100644 frontend/src/pages/AuthPage.tsx create mode 100644 frontend/src/pages/HomePage.test.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/WelcomePage.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbcc8c0..fe95c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,7 +167,15 @@ jobs: bosses=$(curl -sf http://localhost:8080/api/bosses | jq 'length') echo "bosses served: ${bosses}" test "${bosses}" -gt 0 - curl -sf http://localhost:8080/api/sekiro/analytics | jq -e '.total_bosses == '"${bosses}" + + # Analytics need a login: anonymous requests are refused, and a + # session cookie from registering works through nginx. + status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/api/sekiro/analytics) + test "${status}" = 401 + curl -sf -c cookies.txt -H 'Content-Type: application/json' \ + -d '{"username": "ci_smoke", "password": "ci-smoke-password"}' \ + http://localhost:8080/api/auth/register > /dev/null + curl -sf -b cookies.txt http://localhost:8080/api/sekiro/analytics | jq -e '.total_bosses == '"${bosses}" - name: Show container logs if: failure() diff --git a/README.md b/README.md index f4d293c..59fd416 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ Requires [Docker](https://www.docker.com/) (Docker Desktop on Windows or macOS). On every start the backend applies database migrations and loads the bosses from `backend/seed/bosses.json`. -3. Open http://localhost:8080. The API documentation is at http://localhost:8000/docs. +3. Open http://localhost:8080 and create an account. Boss movesets can be browsed without one; recording attempts and viewing analytics need a login. The API documentation is at http://localhost:8000/docs. To open the app on a phone on the same network, use `http://:8080`. On Windows, find the IP with `ipconfig` under the Ethernet or Wi-Fi adapter, not the `vEthernet (WSL)` one. diff --git a/README.zh-CN.md b/README.zh-CN.md index 147d2f8..8b1479c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -282,7 +282,7 @@ V1 刻意使用 JSON 持久化,因为初始数据集很小,主要目标是 后端每次启动时都会执行数据库迁移,并从 `backend/seed/bosses.json` 同步 Boss 数据。 -3. 打开 http://localhost:8080。API 文档在 http://localhost:8000/docs。 +3. 打开 http://localhost:8080 并注册一个账号。不登录也可以浏览 Boss 的招式,但记录尝试和查看分析需要登录。API 文档在 http://localhost:8000/docs。 在同一网络下用手机访问时,打开 `http://<电脑的局域网 IP>:8080`。Windows 上可以用 `ipconfig` 查看 IP,要看 Ethernet 或 Wi-Fi 网卡下面的地址,不是 `vEthernet (WSL)` 那一个。 diff --git a/backend/alembic/versions/980c7a879ddb_add_users_and_sessions.py b/backend/alembic/versions/980c7a879ddb_add_users_and_sessions.py new file mode 100644 index 0000000..9f558c2 --- /dev/null +++ b/backend/alembic/versions/980c7a879ddb_add_users_and_sessions.py @@ -0,0 +1,53 @@ +"""add users and sessions + +Revision ID: 980c7a879ddb +Revises: 49210234f07d +Create Date: 2026-09-23 19:47:11.708103 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '980c7a879ddb' +down_revision: Union[str, Sequence[str], None] = '49210234f07d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=30), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('username = lower(username)', name='ck_users_username_lowercase'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('user_sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions') + op.drop_table('user_sessions') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..94d8ab5 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,44 @@ +"""Session cookie handling and the dependency that requires a logged-in user.""" + +import os + +from fastapi import Cookie, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app.db.models import User +from app.db.session import get_db +from app.services import auth_service + +SESSION_COOKIE = "session" + + +def _cookie_secure() -> bool: + # Local development runs over plain HTTP, so Secure is opt-in until the + # HTTPS deployment (V3 Milestone 4) sets COOKIE_SECURE=true. + return os.environ.get("COOKIE_SECURE", "false").lower() == "true" + + +def set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + SESSION_COOKIE, + token, + max_age=int(auth_service.SESSION_LIFETIME.total_seconds()), + httponly=True, + samesite="lax", + secure=_cookie_secure(), + path="/", + ) + + +def clear_session_cookie(response: Response) -> None: + response.delete_cookie(SESSION_COOKIE, httponly=True, samesite="lax", secure=_cookie_secure(), path="/") + + +def get_current_user( + session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE), + db: Session = Depends(get_db), +) -> User: + user = auth_service.get_user_for_token(db, session_token) if session_token else None + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not logged in") + return user diff --git a/backend/app/db/models.py b/backend/app/db/models.py index b5ffb36..80861d4 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -120,3 +120,32 @@ class Attempt(Base): boss: Mapped[Boss] = relationship() failure_move: Mapped[Move | None] = relationship() + + +class User(Base): + __tablename__ = "users" + __table_args__ = ( + CheckConstraint("username = lower(username)", name="ck_users_username_lowercase"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + # Stored lowercase so usernames are unique regardless of case. + username: Mapped[str] = mapped_column(String(30), unique=True) + password_hash: Mapped[str] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + sessions: Mapped[list["UserSession"]] = relationship(back_populates="user", cascade="all, delete-orphan") + + +class UserSession(Base): + """A login session. Only a SHA-256 hash of the cookie token is stored.""" + + __tablename__ = "user_sessions" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="sessions") diff --git a/backend/app/main.py b/backend/app/main.py index c179bdc..c022fd0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,10 @@ from fastapi import FastAPI -from app.routers import attempts, bosses, sekiro +from app.routers import attempts, auth, bosses, sekiro app = FastAPI(title="Sekiro Boss Practice Analytics") +app.include_router(auth.router) app.include_router(bosses.router) app.include_router(attempts.router) app.include_router(sekiro.router) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..80aee60 --- /dev/null +++ b/backend/app/models/auth.py @@ -0,0 +1,33 @@ +import re + +from pydantic import BaseModel, Field, field_validator + +USERNAME_PATTERN = r"^[a-z0-9_-]+$" + + +class RegisterRequest(BaseModel): + username: str = Field(min_length=3, max_length=30) + # Upper bound keeps a single request from making argon2 hash megabytes of input. + password: str = Field(min_length=8, max_length=128) + + @field_validator("username", mode="before") + @classmethod + def normalize_username(cls, value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + @field_validator("username") + @classmethod + def check_username_characters(cls, value: str) -> str: + if not re.fullmatch(USERNAME_PATTERN, value): + raise ValueError("Username may only contain letters, digits, '_' and '-'") + return value + + +class LoginRequest(BaseModel): + username: str = Field(max_length=100) + password: str = Field(max_length=128) + + +class User(BaseModel): + id: str + username: str diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 3e1a7ea..12b10bd 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -1,12 +1,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session +from app.auth import get_current_user from app.db.session import get_db from app.models.analytics import BossAnalytics, ProgressionPoint from app.models.attempt import Attempt, CreateAttemptRequest from app.services import analytics_service, attempt_service, boss_service -router = APIRouter(prefix="/api/bosses/{boss_id}", tags=["attempts"]) +# Attempts and analytics require login. Until V3 Milestone 2 gives attempts an +# owner, every logged-in user still shares one attempt history. +router = APIRouter( + prefix="/api/bosses/{boss_id}", tags=["attempts"], dependencies=[Depends(get_current_user)] +) def _ensure_boss_exists(db: Session, boss_id: str) -> None: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..69423f6 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app.auth import SESSION_COOKIE, clear_session_cookie, get_current_user, set_session_cookie +from app.db import models +from app.db.session import get_db +from app.models.auth import LoginRequest, RegisterRequest, User +from app.services import auth_service + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _to_response(user: models.User) -> User: + return User(id=str(user.id), username=user.username) + + +@router.post("/register", response_model=User, status_code=status.HTTP_201_CREATED) +def register(req: RegisterRequest, response: Response, db: Session = Depends(get_db)): + try: + user = auth_service.register_user(db, req.username, req.password) + except auth_service.UsernameTakenError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + set_session_cookie(response, auth_service.create_session(db, user)) + return _to_response(user) + + +@router.post("/login", response_model=User) +def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)): + user = auth_service.authenticate(db, req.username, req.password) + if user is None: + # Same message for an unknown user and a wrong password. + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password") + set_session_cookie(response, auth_service.create_session(db, user)) + return _to_response(user) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout( + response: Response, + session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE), + db: Session = Depends(get_db), +): + if session_token: + auth_service.end_session(db, session_token) + clear_session_cookie(response) + + +@router.get("/me", response_model=User) +def me(user: models.User = Depends(get_current_user)): + return _to_response(user) diff --git a/backend/app/routers/sekiro.py b/backend/app/routers/sekiro.py index 3cc91e0..b6df1b1 100644 --- a/backend/app/routers/sekiro.py +++ b/backend/app/routers/sekiro.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session +from app.auth import get_current_user from app.db.session import get_db from app.models.analytics import SekiroAnalytics from app.services import game_analytics_service -router = APIRouter(prefix="/api/sekiro", tags=["sekiro"]) +router = APIRouter(prefix="/api/sekiro", tags=["sekiro"], dependencies=[Depends(get_current_user)]) @router.get("/analytics", response_model=SekiroAnalytics) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..73b996d --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,90 @@ +"""Accounts, password hashing and login sessions. + +Sessions are stored server-side so logging out really ends them. The cookie +holds a random token; the database only keeps its SHA-256 hash, so a leaked +database does not leak usable session tokens. +""" + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.db.models import User, UserSession + +SESSION_LIFETIME = timedelta(days=30) + +_hasher = PasswordHasher() + + +class UsernameTakenError(Exception): + pass + + +def normalize_username(username: str) -> str: + return username.strip().lower() + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def get_user_by_username(db: Session, username: str) -> User | None: + return db.scalar(select(User).where(User.username == normalize_username(username))) + + +def register_user(db: Session, username: str, password: str) -> User: + username = normalize_username(username) + if get_user_by_username(db, username) is not None: + raise UsernameTakenError(f"Username '{username}' is already taken") + user = User(username=username, password_hash=_hasher.hash(password)) + db.add(user) + db.commit() + return user + + +def authenticate(db: Session, username: str, password: str) -> User | None: + user = get_user_by_username(db, username) + if user is None: + return None + try: + _hasher.verify(user.password_hash, password) + except (VerifyMismatchError, InvalidHashError): + return None + if _hasher.check_needs_rehash(user.password_hash): + user.password_hash = _hasher.hash(password) + db.commit() + return user + + +def create_session(db: Session, user: User) -> str: + """Starts a session for the user and returns the raw token for the cookie.""" + db.execute(delete(UserSession).where(UserSession.user_id == user.id, UserSession.expires_at <= _now())) + token = secrets.token_urlsafe(32) + db.add(UserSession(user_id=user.id, token_hash=_hash_token(token), expires_at=_now() + SESSION_LIFETIME)) + db.commit() + return token + + +def get_user_for_token(db: Session, token: str) -> User | None: + session = db.scalar(select(UserSession).where(UserSession.token_hash == _hash_token(token))) + if session is None: + return None + if session.expires_at <= _now(): + db.delete(session) + db.commit() + return None + return session.user + + +def end_session(db: Session, token: str) -> None: + db.execute(delete(UserSession).where(UserSession.token_hash == _hash_token(token))) + db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt index 311bf2b..ef9b4f7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ alembic psycopg[binary] pytest httpx +argon2-cffi diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 146abe4..d501cb0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -49,7 +49,16 @@ def seeded_session(session): @pytest.fixture() -def client(seeded_session): +def anon_client(seeded_session): + """A client that is not logged in.""" app.dependency_overrides[get_db] = lambda: seeded_session yield TestClient(app) app.dependency_overrides.clear() + + +@pytest.fixture() +def client(anon_client): + """A client logged in as a freshly registered user; the session cookie is kept between requests.""" + resp = anon_client.post("/api/auth/register", json={"username": "tester", "password": "correct-horse"}) + assert resp.status_code == 201, resp.text + return anon_client diff --git a/backend/tests/db/test_schema.py b/backend/tests/db/test_schema.py index 253b614..0f9c6e1 100644 --- a/backend/tests/db/test_schema.py +++ b/backend/tests/db/test_schema.py @@ -13,7 +13,7 @@ from app.db.models import Attempt, Boss, BossPhase, Game, Move, PhaseMove from scripts.reset_test_database import alembic_config -APP_TABLES = {"games", "bosses", "boss_phases", "moves", "phase_moves", "attempts"} +APP_TABLES = {"games", "bosses", "boss_phases", "moves", "phase_moves", "attempts", "users", "user_sessions"} def make_boss(session: Session, slug: str = "owl-father") -> Boss: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..47211d6 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,130 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select + +from app.auth import SESSION_COOKIE +from app.db.models import User, UserSession + +CREDENTIALS = {"username": "wolf", "password": "kusabimaru"} + + +def register(client, **overrides): + return client.post("/api/auth/register", json={**CREDENTIALS, **overrides}) + + +def test_register_logs_the_user_in_with_an_httponly_cookie(anon_client): + resp = register(anon_client) + + assert resp.status_code == 201 + assert resp.json()["username"] == "wolf" + set_cookie = resp.headers["set-cookie"].lower() + assert f"{SESSION_COOKIE}=" in set_cookie + assert "httponly" in set_cookie + assert "samesite=lax" in set_cookie + assert anon_client.get("/api/auth/me").json()["username"] == "wolf" + + +def test_username_is_case_insensitive_and_stored_lowercase(anon_client): + assert register(anon_client, username=" Wolf ").json()["username"] == "wolf" + + resp = register(anon_client, username="WOLF") + assert resp.status_code == 409 + + +@pytest.mark.parametrize( + "overrides", + [ + {"username": "ab"}, + {"username": "has space"}, + {"username": "x" * 31}, + {"password": "short"}, + {"password": "p" * 129}, + ], + ids=["username-too-short", "username-bad-characters", "username-too-long", "password-too-short", "password-too-long"], +) +def test_register_rejects_invalid_input(anon_client, overrides): + assert register(anon_client, **overrides).status_code == 422 + + +def test_passwords_and_session_tokens_are_stored_hashed(anon_client, seeded_session): + register(anon_client) + token = anon_client.cookies[SESSION_COOKIE] + + user = seeded_session.scalar(select(User).where(User.username == "wolf")) + assert user.password_hash.startswith("$argon2") + assert CREDENTIALS["password"] not in user.password_hash + stored = seeded_session.scalars(select(UserSession.token_hash)).all() + assert token not in stored + + +def test_login_with_correct_password(anon_client): + register(anon_client) + anon_client.cookies.clear() + + resp = anon_client.post("/api/auth/login", json={"username": "WOLF", "password": "kusabimaru"}) + + assert resp.status_code == 200 + assert anon_client.get("/api/auth/me").status_code == 200 + + +@pytest.mark.parametrize( + "credentials", + [{"username": "wolf", "password": "wrong-password"}, {"username": "nobody", "password": "kusabimaru"}], + ids=["wrong-password", "unknown-user"], +) +def test_login_failures_give_the_same_answer(anon_client, credentials): + register(anon_client) + anon_client.cookies.clear() + + resp = anon_client.post("/api/auth/login", json=credentials) + + assert resp.status_code == 401 + assert resp.json()["detail"] == "Incorrect username or password" + assert SESSION_COOKIE not in anon_client.cookies + + +def test_logout_ends_the_session_on_the_server(anon_client): + register(anon_client) + token = anon_client.cookies[SESSION_COOKIE] + + assert anon_client.post("/api/auth/logout").status_code == 204 + + # Replaying the old cookie must not work once the session is gone. + anon_client.cookies.set(SESSION_COOKIE, token) + assert anon_client.get("/api/auth/me").status_code == 401 + + +def test_expired_session_is_rejected_and_removed(anon_client, seeded_session): + register(anon_client) + session = seeded_session.scalar(select(UserSession)) + session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + seeded_session.commit() + + assert anon_client.get("/api/auth/me").status_code == 401 + assert seeded_session.scalar(select(UserSession)) is None + + +def test_unknown_session_token_is_rejected(anon_client): + anon_client.cookies.set(SESSION_COOKIE, "made-up-token") + assert anon_client.get("/api/auth/me").status_code == 401 + + +def test_boss_data_is_public(anon_client): + assert anon_client.get("/api/bosses").status_code == 200 + assert anon_client.get("/api/bosses/genichiro-ashina").status_code == 200 + + +@pytest.mark.parametrize( + "method, path", + [ + ("get", "/api/bosses/genichiro-ashina/attempts"), + ("post", "/api/bosses/genichiro-ashina/attempts"), + ("get", "/api/bosses/genichiro-ashina/analytics"), + ("get", "/api/bosses/genichiro-ashina/progression"), + ("get", "/api/sekiro/analytics"), + ], +) +def test_attempts_and_analytics_require_login(anon_client, method, path): + kwargs = {"json": {"result": "victory"}} if method == "post" else {} + assert getattr(anon_client, method)(path, **kwargs).status_code == 401 diff --git a/frontend/integration-tests/attemptWorkflow.test.ts b/frontend/integration-tests/attemptWorkflow.test.ts index 1bb0c50..a126123 100644 --- a/frontend/integration-tests/attemptWorkflow.test.ts +++ b/frontend/integration-tests/attemptWorkflow.test.ts @@ -25,6 +25,30 @@ const DATABASE_NAME = "sekiro_integration_test"; let backendProcess: ChildProcess; +// Node's fetch has no cookie jar. Browsers keep the session cookie automatically; +// this keeps it for the real api/ layer the same way. +const cookieJar = new Map(); + +function installCookieJar(): void { + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + if (cookieJar.size > 0) { + headers.set("Cookie", [...cookieJar].map(([name, value]) => `${name}=${value}`).join("; ")); + } + const res = await realFetch(input, { ...init, headers }); + for (const cookie of res.headers.getSetCookie()) { + const [pair] = cookie.split(";"); + const separator = pair.indexOf("="); + const name = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim().replace(/^"|"$/g, ""); + if (!value || /max-age=0/i.test(cookie)) cookieJar.delete(name); + else cookieJar.set(name, value); + } + return res; + }); +} + function readDotEnv(): Record { const path = join(REPO_DIR, ".env"); if (!existsSync(path)) return {}; @@ -92,6 +116,7 @@ beforeAll(async () => { backendProcess.stderr?.on("data", (chunk) => startupErrors.push(String(chunk))); vi.stubEnv("VITE_API_BASE_URL", API_BASE_URL); + installCookieJar(); try { await waitForHealth(15000); @@ -103,6 +128,7 @@ beforeAll(async () => { afterAll(() => { backendProcess?.kill(); vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); describe("frontend calling the real backend", () => { @@ -140,6 +166,21 @@ describe("frontend calling the real backend", () => { await expect(getBossById("nonexistent-boss")).rejects.toThrow(ApiError); }); + it("requires login for attempts, then registers and keeps the session", async () => { + const { getCurrentUser, register } = await import("../src/api/auth"); + const { getBossAttempts } = await import("../src/api/attempts"); + const { ApiError } = await import("../src/api/client"); + + expect(await getCurrentUser()).toBeNull(); + await expect(getBossAttempts("genichiro-ashina")).rejects.toMatchObject({ status: 401 }); + await expect(getBossAttempts("genichiro-ashina")).rejects.toThrow(ApiError); + + const user = await register({ username: "Integration_Wolf", password: "kusabimaru" }); + + expect(user.username).toBe("integration_wolf"); + expect(await getCurrentUser()).toEqual(user); + }); + it("records an attempt end-to-end and reflects it in history and analytics", async () => { const { getBossAttempts, createAttempt, getBossAnalytics } = await import("../src/api/attempts"); @@ -213,4 +254,20 @@ describe("frontend calling the real backend", () => { failure_by_move: {}, }); }); + + it("logs out and loses access to attempts again", async () => { + const { getCurrentUser, login, logout } = await import("../src/api/auth"); + const { getBossAttempts } = await import("../src/api/attempts"); + + await logout(); + + expect(await getCurrentUser()).toBeNull(); + await expect(getBossAttempts("genichiro-ashina")).rejects.toMatchObject({ status: 401 }); + + await expect(login({ username: "integration_wolf", password: "wrong-password" })).rejects.toMatchObject({ + status: 401, + }); + await login({ username: "integration_wolf", password: "kusabimaru" }); + expect(await getBossAttempts("genichiro-ashina")).toHaveLength(2); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e8a106..e97be12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,21 @@ import { Route, Routes } from "react-router-dom"; +import NavBar from "./components/NavBar"; +import AuthPage from "./pages/AuthPage"; import BossSelectionPage from "./pages/BossSelectionPage"; import BossDashboardPage from "./pages/BossDashboardPage"; -import SekiroDashboardPage from "./pages/SekiroDashboardPage"; +import HomePage from "./pages/HomePage"; export default function App() { return ( - - } /> - } /> - } /> - + <> + + + } /> + } /> + } /> + } /> + } /> + + ); } diff --git a/frontend/src/api/auth.test.ts b/frontend/src/api/auth.test.ts new file mode 100644 index 0000000..02ea224 --- /dev/null +++ b/frontend/src/api/auth.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getCurrentUser, login } from "./auth"; +import { UNAUTHORIZED_EVENT } from "./client"; + +function mockFetch(status: number, body: unknown) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: "", + json: async () => body, + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("auth api", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts credentials to the login endpoint", async () => { + const fetchMock = mockFetch(200, { id: "1", username: "wolf" }); + + const user = await login({ username: "wolf", password: "kusabimaru" }); + + expect(user).toEqual({ id: "1", username: "wolf" }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/auth/login", + expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "wolf", password: "kusabimaru" }) }) + ); + }); + + it("returns null from getCurrentUser when there is no session", async () => { + mockFetch(401, { detail: "Not logged in" }); + + await expect(getCurrentUser()).resolves.toBeNull(); + }); + + it("still throws other errors from getCurrentUser", async () => { + mockFetch(500, { detail: "boom" }); + + await expect(getCurrentUser()).rejects.toThrow("boom"); + }); + + it("announces a 401 from any request so the app can drop the stale login", async () => { + mockFetch(401, { detail: "Not logged in" }); + const listener = vi.fn(); + window.addEventListener(UNAUTHORIZED_EVENT, listener); + + await getCurrentUser(); + + window.removeEventListener(UNAUTHORIZED_EVENT, listener); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..0f8cb04 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,24 @@ +import { ApiError, apiRequest } from "./client"; +import type { Credentials, User } from "../types"; + +export function register(credentials: Credentials): Promise { + return apiRequest("/auth/register", { method: "POST", body: JSON.stringify(credentials) }); +} + +export function login(credentials: Credentials): Promise { + return apiRequest("/auth/login", { method: "POST", body: JSON.stringify(credentials) }); +} + +export function logout(): Promise { + return apiRequest("/auth/logout", { method: "POST" }); +} + +/** The logged-in user, or null when there is no valid session. */ +export async function getCurrentUser(): Promise { + try { + return await apiRequest("/auth/me"); + } catch (err) { + if (err instanceof ApiError && err.status === 401) return null; + throw err; + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e9671b7..ed4a908 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,6 +8,8 @@ export class ApiError extends Error { } } +export const UNAUTHORIZED_EVENT = "api:unauthorized"; + function getBaseUrl(): string { return import.meta.env.VITE_API_BASE_URL ?? "/api"; } @@ -19,6 +21,10 @@ export async function apiRequest(path: string, options?: RequestInit): Promis }); if (!res.ok) { + if (res.status === 401 && typeof window !== "undefined") { + // Lets the auth state notice an expired or ended session from any request. + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + } const body = await res.json().catch(() => null); throw new ApiError(res.status, body?.detail ?? res.statusText); } diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx new file mode 100644 index 0000000..60e94f1 --- /dev/null +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -0,0 +1,67 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import * as authApi from "../api/auth"; +import { UNAUTHORIZED_EVENT } from "../api/client"; +import AuthProvider from "./AuthProvider"; +import { useAuth } from "./authContext"; + +vi.mock("../api/auth"); + +function Probe() { + const { status, user, login, logout } = useAuth(); + return ( +
+

status: {status}

+

user: {user?.username ?? "none"}

+ + +
+ ); +} + +function renderProbe() { + return render( + + + + ); +} + +describe("AuthProvider", () => { + it("restores the logged-in user from the session cookie", async () => { + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", username: "wolf" }); + + renderProbe(); + + expect(screen.getByText("status: loading")).toBeInTheDocument(); + expect(await screen.findByText("user: wolf")).toBeInTheDocument(); + expect(screen.getByText("status: ready")).toBeInTheDocument(); + }); + + it("logs in and out", async () => { + vi.mocked(authApi.getCurrentUser).mockResolvedValue(null); + vi.mocked(authApi.login).mockResolvedValue({ id: "1", username: "wolf" }); + vi.mocked(authApi.logout).mockResolvedValue(undefined); + renderProbe(); + await screen.findByText("status: ready"); + + await userEvent.click(screen.getByRole("button", { name: "login" })); + expect(await screen.findByText("user: wolf")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "logout" })); + expect(await screen.findByText("user: none")).toBeInTheDocument(); + }); + + it("forgets the user when any request comes back 401", async () => { + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", username: "wolf" }); + renderProbe(); + await screen.findByText("user: wolf"); + + act(() => { + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + }); + + expect(screen.getByText("user: none")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..edd9fee --- /dev/null +++ b/frontend/src/auth/AuthProvider.tsx @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import * as authApi from "../api/auth"; +import { UNAUTHORIZED_EVENT } from "../api/client"; +import type { Credentials, User } from "../types"; +import { AuthContext } from "./authContext"; +import type { AuthState } from "./authContext"; + +export default function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [status, setStatus] = useState("loading"); + + useEffect(() => { + let cancelled = false; + authApi + .getCurrentUser() + .catch(() => null) + .then((current) => { + if (cancelled) return; + setUser(current); + setStatus("ready"); + }); + + const handleUnauthorized = () => setUser(null); + window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); + return () => { + cancelled = true; + window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); + }; + }, []); + + const login = useCallback(async (credentials: Credentials) => { + setUser(await authApi.login(credentials)); + }, []); + + const register = useCallback(async (credentials: Credentials) => { + setUser(await authApi.register(credentials)); + }, []); + + const logout = useCallback(async () => { + try { + await authApi.logout(); + } finally { + setUser(null); + } + }, []); + + const value = useMemo(() => ({ status, user, login, register, logout }), [status, user, login, register, logout]); + + return {children}; +} diff --git a/frontend/src/auth/authContext.ts b/frontend/src/auth/authContext.ts new file mode 100644 index 0000000..e9ee434 --- /dev/null +++ b/frontend/src/auth/authContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; +import type { Credentials, User } from "../types"; + +export interface AuthState { + /** "loading" until the first check of the session cookie finishes. */ + status: "loading" | "ready"; + user: User | null; + login: (credentials: Credentials) => Promise; + register: (credentials: Credentials) => Promise; + logout: () => Promise; +} + +export const AuthContext = createContext(null); + +export function useAuth(): AuthState { + const auth = useContext(AuthContext); + if (!auth) throw new Error("useAuth must be used inside "); + return auth; +} diff --git a/frontend/src/auth/testAuth.tsx b/frontend/src/auth/testAuth.tsx new file mode 100644 index 0000000..93999cc --- /dev/null +++ b/frontend/src/auth/testAuth.tsx @@ -0,0 +1,20 @@ +import type { ReactElement } from "react"; +import { vi } from "vitest"; +import type { User } from "../types"; +import { AuthContext } from "./authContext"; +import type { AuthState } from "./authContext"; + +export const testUser: User = { id: "1", username: "wolf" }; + +/** Wraps `ui` in a fixed auth state for component tests, without touching the network. */ +export function withAuth(ui: ReactElement, overrides: Partial = {}): ReactElement { + const value: AuthState = { + status: "ready", + user: testUser, + login: vi.fn().mockResolvedValue(undefined), + register: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + return {ui}; +} diff --git a/frontend/src/components/BossList.tsx b/frontend/src/components/BossList.tsx new file mode 100644 index 0000000..93ce580 --- /dev/null +++ b/frontend/src/components/BossList.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { getBosses } from "../api/bosses"; +import type { BossSummary } from "../types"; + +type LoadState = "loading" | "error" | "ready"; + +export default function BossList() { + const [bosses, setBosses] = useState([]); + const [state, setState] = useState("loading"); + + useEffect(() => { + let cancelled = false; + setState("loading"); + + getBosses() + .then((data) => { + if (cancelled) return; + setBosses(data); + setState("ready"); + }) + .catch(() => { + if (cancelled) return; + setState("error"); + }); + + return () => { + cancelled = true; + }; + }, []); + + if (state === "loading") return

Loading bosses…

; + if (state === "error") return

Failed to load bosses.

; + + return ( +
    + {bosses.map((boss) => ( +
  • +

    {boss.name}

    + {boss.name_zh &&

    {boss.name_zh}

    } +

    {boss.location}

    + + View Boss + +
  • + ))} +
+ ); +} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx new file mode 100644 index 0000000..9dd3f3d --- /dev/null +++ b/frontend/src/components/NavBar.tsx @@ -0,0 +1,42 @@ +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../auth/authContext"; + +export default function NavBar() { + const { status, user, logout } = useAuth(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout().catch(() => undefined); + navigate("/"); + } + + return ( +
+ + {status === "ready" && ( +
+ {user ? ( + <> + {user.username} + + + ) : ( + <> + Log In + + Register + + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx new file mode 100644 index 0000000..d185eac --- /dev/null +++ b/frontend/src/components/PasswordInput.tsx @@ -0,0 +1,60 @@ +import { useId, useState } from "react"; + +interface PasswordInputProps { + value: string; + onChange: (value: string) => void; + autoComplete: "current-password" | "new-password"; +} + +function EyeIcon({ crossed }: { crossed: boolean }) { + return ( + + ); +} + +/** A labelled password field with a button that shows or hides what was typed. */ +export default function PasswordInput({ value, onChange, autoComplete }: PasswordInputProps) { + const id = useId(); + const [visible, setVisible] = useState(false); + + return ( +
+ +
+ onChange(e.target.value)} + required + /> + +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index af55fe7..6ae9797 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -340,3 +340,127 @@ a { [role="alert"] { color: var(--color-failed); } + +.nav-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.nav-bar nav, +.nav-account { + display: flex; + align-items: center; + gap: 1rem; +} + +.nav-bar a:not(.btn) { + color: var(--color-text); + text-decoration: none; +} + +.nav-bar a:not(.btn):hover { + color: var(--color-primary-hover); +} + +.nav-brand { + font-weight: 600; +} + +.nav-username { + color: var(--color-text-muted); +} + +.auth-page { + max-width: 420px; +} + +.auth-form { + display: grid; + gap: 1rem; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1.25rem; + background: var(--color-surface); +} + +.auth-form label { + display: grid; + gap: 0.35rem; + font-size: 0.9rem; +} + +.auth-form input { + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.6rem; + font: inherit; +} + +.form-hint { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.welcome-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.login-prompt { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1rem 1.25rem; + background: var(--color-surface); +} + +.form-field { + display: grid; + gap: 0.35rem; + font-size: 0.9rem; +} + +.password-field { + position: relative; +} + +.password-field input { + width: 100%; + padding-right: 2.75rem; +} + +.password-toggle { + position: absolute; + top: 50%; + right: 0.35rem; + transform: translateY(-50%); + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; +} + +.password-toggle:hover, +.password-toggle[aria-pressed="true"] { + color: var(--color-text); +} + +.password-toggle:focus-visible { + outline: 2px solid var(--color-primary-hover); + outline-offset: 1px; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b39df04..23000a1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,12 +2,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import AuthProvider from "./auth/AuthProvider"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + ); diff --git a/frontend/src/pages/AuthPage.test.tsx b/frontend/src/pages/AuthPage.test.tsx new file mode 100644 index 0000000..1c01690 --- /dev/null +++ b/frontend/src/pages/AuthPage.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../api/client"; +import { withAuth } from "../auth/testAuth"; +import type { AuthState } from "../auth/authContext"; +import AuthPage from "./AuthPage"; + +function renderAuthPage(mode: "login" | "register", auth: Partial, from?: string) { + return render( + withAuth( + + + } /> + } /> + home page

} /> + boss page

} /> +
+
, + auth + ) + ); +} + +async function fillAndSubmit(button: string) { + await userEvent.type(screen.getByLabelText("Username"), "wolf"); + await userEvent.type(screen.getByLabelText("Password"), "kusabimaru"); + await userEvent.click(screen.getByRole("button", { name: button })); +} + +describe("AuthPage", () => { + it("logs in and returns to the page the user came from", async () => { + const login = vi.fn().mockResolvedValue(undefined); + renderAuthPage("login", { user: null, login }, "/bosses/genichiro-ashina"); + + await fillAndSubmit("Log In"); + + expect(login).toHaveBeenCalledWith({ username: "wolf", password: "kusabimaru" }); + expect(await screen.findByText("boss page")).toBeInTheDocument(); + }); + + it("shows a clear message for wrong credentials", async () => { + const login = vi.fn().mockRejectedValue(new ApiError(401, "Incorrect username or password")); + renderAuthPage("login", { user: null, login }); + + await fillAndSubmit("Log In"); + + expect(await screen.findByRole("alert")).toHaveTextContent("Incorrect username or password."); + expect(screen.getByRole("button", { name: "Log In" })).toBeEnabled(); + }); + + it("registers and goes to the home page", async () => { + const register = vi.fn().mockResolvedValue(undefined); + renderAuthPage("register", { user: null, register }); + + await fillAndSubmit("Register"); + + expect(register).toHaveBeenCalledWith({ username: "wolf", password: "kusabimaru" }); + expect(await screen.findByText("home page")).toBeInTheDocument(); + }); + + it("explains a taken username", async () => { + const register = vi.fn().mockRejectedValue(new ApiError(409, "Username 'wolf' is already taken")); + renderAuthPage("register", { user: null, register }); + + await fillAndSubmit("Register"); + + expect(await screen.findByRole("alert")).toHaveTextContent("That username is already taken."); + }); + + it("sends an already logged-in user straight on", () => { + renderAuthPage("login", {}); + + expect(screen.getByText("home page")).toBeInTheDocument(); + }); + + it("shows and hides the typed password with the eye button", async () => { + renderAuthPage("login", { user: null }); + const password = screen.getByLabelText("Password"); + await userEvent.type(password, "kusabimaru"); + + expect(password).toHaveAttribute("type", "password"); + + await userEvent.click(screen.getByRole("button", { name: "Show password" })); + expect(password).toHaveAttribute("type", "text"); + expect(password).toHaveValue("kusabimaru"); + + await userEvent.click(screen.getByRole("button", { name: "Hide password" })); + expect(password).toHaveAttribute("type", "password"); + }); +}); diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx new file mode 100644 index 0000000..bbf7524 --- /dev/null +++ b/frontend/src/pages/AuthPage.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; +import { ApiError } from "../api/client"; +import { useAuth } from "../auth/authContext"; +import PasswordInput from "../components/PasswordInput"; + +type Mode = "login" | "register"; + +interface LocationState { + from?: string; +} + +const COPY: Record = { + login: { + title: "Log In", + submit: "Log In", + switchText: "No account yet?", + switchLink: "Register", + switchTo: "/register", + }, + register: { + title: "Create an Account", + submit: "Register", + switchText: "Already have an account?", + switchLink: "Log in", + switchTo: "/login", + }, +}; + +const REGISTER_RULES = "Username: 3–30 letters, digits, '_' or '-'. Password: at least 8 characters."; + +function errorMessage(err: unknown, mode: Mode): string { + if (err instanceof ApiError) { + if (err.status === 401) return "Incorrect username or password."; + if (err.status === 409) return "That username is already taken."; + if (err.status === 422) return mode === "register" ? REGISTER_RULES : "Enter your username and password."; + } + return "Something went wrong. Please try again."; +} + +export default function AuthPage({ mode }: { mode: Mode }) { + const { status, user, login, register } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as LocationState | null)?.from ?? "/"; + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Already logged in (e.g. opened /login directly): go where they were headed. + if (status === "ready" && user && !submitting) { + return ; + } + + const copy = COPY[mode]; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await (mode === "login" ? login : register)({ username, password }); + navigate(from, { replace: true }); + } catch (err) { + setError(errorMessage(err, mode)); + setSubmitting(false); + } + } + + return ( +
+

{copy.title}

+
+ + + {mode === "register" &&

{REGISTER_RULES}

} + {error &&

{error}

} + + +

+ {copy.switchText}{" "} + + {copy.switchLink} + +

+
+ ); +} diff --git a/frontend/src/pages/BossDashboardPage.test.tsx b/frontend/src/pages/BossDashboardPage.test.tsx index a8c710e..be0c82d 100644 --- a/frontend/src/pages/BossDashboardPage.test.tsx +++ b/frontend/src/pages/BossDashboardPage.test.tsx @@ -6,6 +6,8 @@ import BossDashboardPage from "./BossDashboardPage"; import * as bossesApi from "../api/bosses"; import * as attemptsApi from "../api/attempts"; import { ApiError } from "../api/client"; +import { withAuth } from "../auth/testAuth"; +import type { AuthState } from "../auth/authContext"; import type { Boss, BossAnalytics } from "../types"; vi.mock("../api/bosses"); @@ -42,13 +44,16 @@ const emptyAnalytics: BossAnalytics = { recent: { window_size: 10, total_attempts: 0, main_bottleneck_phase: null, most_common_failure_move: null, failure_by_phase: {}, failure_by_move: {} }, }; -function renderDashboard() { +function renderDashboard(auth: Partial = {}) { return render( - - - } /> - - + withAuth( + + + } /> + + , + auth + ) ); } @@ -153,4 +158,18 @@ describe("BossDashboardPage", () => { const card = (await screen.findByRole("heading", { name: "First Victory" })).closest("div") as HTMLElement; expect(card).toHaveTextContent("Attempt #8"); }); + + it("shows only the public boss data and a login prompt to visitors", async () => { + vi.mocked(bossesApi.getBossById).mockResolvedValue(boss); + + renderDashboard({ user: null }); + + expect(await screen.findByRole("heading", { name: "Genichiro Ashina" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); + expect(screen.getByText("Thrust Attack")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /record attempt/i })).not.toBeInTheDocument(); + expect(attemptsApi.getBossAttempts).not.toHaveBeenCalled(); + expect(attemptsApi.getBossAnalytics).not.toHaveBeenCalled(); + expect(attemptsApi.getBossProgression).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/pages/BossDashboardPage.tsx b/frontend/src/pages/BossDashboardPage.tsx index 9738276..67d141a 100644 --- a/frontend/src/pages/BossDashboardPage.tsx +++ b/frontend/src/pages/BossDashboardPage.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useLocation, useParams } from "react-router-dom"; import { getBossById } from "../api/bosses"; import { getBossAnalytics, getBossAttempts, getBossProgression } from "../api/attempts"; import { ApiError } from "../api/client"; +import { useAuth } from "../auth/authContext"; import type { Attempt, Boss, BossAnalytics, ProgressionPoint } from "../types"; import RecordAttemptForm from "../components/RecordAttemptForm"; import AnalyticsPanel from "../components/AnalyticsPanel"; @@ -14,6 +15,9 @@ type LoadState = "loading" | "error" | "not-found" | "ready"; export default function BossDashboardPage() { const { bossId } = useParams<{ bossId: string }>(); + const { status: authStatus, user } = useAuth(); + const location = useLocation(); + const loggedIn = user !== null; const [boss, setBoss] = useState(null); const [attempts, setAttempts] = useState([]); const [analytics, setAnalytics] = useState(null); @@ -33,11 +37,21 @@ export default function BossDashboardPage() { }, []); useEffect(() => { - if (!bossId) return; + if (!bossId || authStatus === "loading") return; let cancelled = false; setState("loading"); - Promise.all([getBossById(bossId), getBossAttempts(bossId), getBossAnalytics(bossId), getBossProgression(bossId)]) + // Visitors only get the public boss data; attempts and analytics need a login. + const load = loggedIn + ? Promise.all([ + getBossById(bossId), + getBossAttempts(bossId), + getBossAnalytics(bossId), + getBossProgression(bossId), + ]) + : getBossById(bossId).then((b) => [b, [] as Attempt[], null, [] as ProgressionPoint[]] as const); + + load .then(([bossData, attemptsData, analyticsData, progressionData]) => { if (cancelled) return; setBoss(bossData); @@ -54,7 +68,7 @@ export default function BossDashboardPage() { return () => { cancelled = true; }; - }, [bossId]); + }, [bossId, authStatus, loggedIn]); async function handleAttemptSaved() { setShowForm(false); @@ -80,7 +94,7 @@ export default function BossDashboardPage() { ); } - if (state === "error" || !boss || !analytics) { + if (state === "error" || !boss || (loggedIn && !analytics)) { return (

Failed to load boss dashboard.

@@ -98,40 +112,55 @@ export default function BossDashboardPage() { {boss.name_zh &&

{boss.name_zh}

}

{boss.location}

-
-
-

Attempts

-

{analytics.total_attempts}

-
-
-

Best Result

-

{analytics.best_phase !== null ? `Phase ${analytics.best_phase}` : "—"}

-
-
-

Defeated

-

{analytics.defeated ? "Yes" : "No"}

-
-
-

First Victory

-

- {analytics.attempts_until_first_victory !== null - ? `Attempt #${analytics.attempts_until_first_victory}` - : "Not yet"} -

-
-
- - - - {showForm && ( - setShowForm(false)} /> + {!analytics ? ( +

+ + Log in + {" "} + or{" "} + + create an account + {" "} + to record attempts and see your analytics for this boss. +

+ ) : ( + <> +
+
+

Attempts

+

{analytics.total_attempts}

+
+
+

Best Result

+

{analytics.best_phase !== null ? `Phase ${analytics.best_phase}` : "—"}

+
+
+

Defeated

+

{analytics.defeated ? "Yes" : "No"}

+
+
+

First Victory

+

+ {analytics.attempts_until_first_victory !== null + ? `Attempt #${analytics.attempts_until_first_victory}` + : "Not yet"} +

+
+
+ + + + {showForm && ( + setShowForm(false)} /> + )} + + + + + )} - - - -
); diff --git a/frontend/src/pages/BossSelectionPage.test.tsx b/frontend/src/pages/BossSelectionPage.test.tsx index 7c78987..94a8b7c 100644 --- a/frontend/src/pages/BossSelectionPage.test.tsx +++ b/frontend/src/pages/BossSelectionPage.test.tsx @@ -25,7 +25,7 @@ describe("BossSelectionPage", () => { expect(await screen.findByText("Genichiro Ashina")).toBeInTheDocument(); expect(screen.getByText("苇名弦一郎")).toBeInTheDocument(); expect(screen.getByRole("link", { name: /view boss/i })).toHaveAttribute("href", "/bosses/genichiro-ashina"); - expect(screen.getByRole("link", { name: /sekiro dashboard/i })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /home/i })).toHaveAttribute("href", "/"); }); it("shows an error message when the boss list fails to load", async () => { diff --git a/frontend/src/pages/BossSelectionPage.tsx b/frontend/src/pages/BossSelectionPage.tsx index 316f63e..3b67175 100644 --- a/frontend/src/pages/BossSelectionPage.tsx +++ b/frontend/src/pages/BossSelectionPage.tsx @@ -1,59 +1,15 @@ -import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { getBosses } from "../api/bosses"; -import type { BossSummary } from "../types"; - -type LoadState = "loading" | "error" | "ready"; +import BossList from "../components/BossList"; export default function BossSelectionPage() { - const [bosses, setBosses] = useState([]); - const [state, setState] = useState("loading"); - - useEffect(() => { - let cancelled = false; - setState("loading"); - - getBosses() - .then((data) => { - if (cancelled) return; - setBosses(data); - setState("ready"); - }) - .catch(() => { - if (cancelled) return; - setState("error"); - }); - - return () => { - cancelled = true; - }; - }, []); - return (
- ← Sekiro Dashboard + ← Home

Sekiro Boss Practice Analytics

Choose a Boss

- - {state === "loading" &&

Loading bosses…

} - {state === "error" &&

Failed to load bosses.

} - - {state === "ready" && ( -
    - {bosses.map((boss) => ( -
  • -

    {boss.name}

    - {boss.name_zh &&

    {boss.name_zh}

    } -

    {boss.location}

    - - View Boss - -
  • - ))} -
- )} +
); } diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..a20f143 --- /dev/null +++ b/frontend/src/pages/HomePage.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import * as bossesApi from "../api/bosses"; +import * as sekiroApi from "../api/sekiro"; +import { withAuth } from "../auth/testAuth"; +import type { AuthState } from "../auth/authContext"; +import NavBar from "../components/NavBar"; +import HomePage from "./HomePage"; + +vi.mock("../api/bosses"); +vi.mock("../api/sekiro"); + +function renderHome(auth: Partial) { + return render( + withAuth( + + + + , + auth + ) + ); +} + +describe("HomePage and NavBar", () => { + it("welcomes visitors with the boss list and login links", async () => { + vi.mocked(bossesApi.getBosses).mockResolvedValue([ + { id: "genichiro-ashina", name: "Genichiro Ashina", name_zh: "苇名弦一郎", location: "Ashina Castle" }, + ]); + + renderHome({ user: null }); + + expect(await screen.findByText("Genichiro Ashina")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Create an Account" })).toHaveAttribute("href", "/register"); + // One in the nav bar, one in the welcome text. + for (const link of screen.getAllByRole("link", { name: "Log In" })) { + expect(link).toHaveAttribute("href", "/login"); + } + expect(sekiroApi.getSekiroAnalytics).not.toHaveBeenCalled(); + }); + + it("shows a logged-in player their dashboard and a logout button", async () => { + vi.mocked(sekiroApi.getSekiroAnalytics).mockReturnValue(new Promise(() => undefined)); + + renderHome({}); + + expect(screen.getByText("wolf")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Log Out" })).toBeInTheDocument(); + expect(sekiroApi.getSekiroAnalytics).toHaveBeenCalled(); + expect(bossesApi.getBosses).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..2c5f3e0 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,18 @@ +import { useAuth } from "../auth/authContext"; +import SekiroDashboardPage from "./SekiroDashboardPage"; +import WelcomePage from "./WelcomePage"; + +/** Logged-in players see their Sekiro dashboard; visitors see the welcome page and boss list. */ +export default function HomePage() { + const { status, user } = useAuth(); + + if (status === "loading") { + return ( +
+

Loading…

+
+ ); + } + + return user ? : ; +} diff --git a/frontend/src/pages/WelcomePage.tsx b/frontend/src/pages/WelcomePage.tsx new file mode 100644 index 0000000..6fac0e5 --- /dev/null +++ b/frontend/src/pages/WelcomePage.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; +import BossList from "../components/BossList"; + +export default function WelcomePage() { + return ( +
+

Sekiro Boss Practice Analytics

+

+ Record each boss attempt in a few seconds (how far you got and what ended the attempt) and see which phases and + moves are holding you back, and whether you are improving. +

+

+ + Create an Account + + + Log In + +

+

Bosses

+

Browse each boss's phases and moveset without an account.

+ +
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8b21e2c..38a2dec 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -131,3 +131,13 @@ export interface SekiroAnalytics { recent_attempts: RecentAttempt[]; bosses: BossComparisonRow[]; } + +export interface User { + id: string; + username: string; +} + +export interface Credentials { + username: string; + password: string; +} From 6018b24645ddac1527070cbd67845de9d77f600f Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 21:32:59 -0400 Subject: [PATCH 03/19] Give attempts an owner and scope analytics per user (V3 Milestones 2-3) - attempts.user_id (nullable FK) plus a (user_id, boss_id) index, in a reviewed migration that keeps existing attempts as ownerless - every attempt read and write, boss analytics, progression and the Sekiro dashboard queries are scoped to the logged-in user - ownerless pre-account attempts are hidden from everyone until `python -m scripts.claim_attempts ` assigns them - isolation tests at API and service level, claim script tests, a migration test on a database with existing attempts, and a second user in the integration workflow - README: how to claim V1/V2 attempts after upgrading; CLAUDE.md: make user_id NOT NULL in Milestone 4 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + README.md | 8 + README.zh-CN.md | 8 + .../30b90438d428_add_attempts_user_id.py | 37 ++++ backend/app/db/models.py | 7 + backend/app/routers/attempts.py | 28 +-- backend/app/routers/sekiro.py | 7 +- backend/app/services/analytics_service.py | 10 +- backend/app/services/attempt_service.py | 8 +- .../app/services/game_analytics_service.py | 34 ++-- backend/scripts/claim_attempts.py | 57 ++++++ backend/tests/conftest.py | 14 ++ backend/tests/db/test_schema.py | 28 +++ backend/tests/test_sekiro_analytics.py | 14 +- backend/tests/test_user_isolation.py | 168 ++++++++++++++++++ .../integration-tests/attemptWorkflow.test.ts | 35 ++++ 16 files changed, 424 insertions(+), 41 deletions(-) create mode 100644 backend/alembic/versions/30b90438d428_add_attempts_user_id.py create mode 100644 backend/scripts/claim_attempts.py create mode 100644 backend/tests/test_user_isolation.py diff --git a/CLAUDE.md b/CLAUDE.md index 4061349..013ebc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,7 @@ Implement V3 incrementally. Each milestone should leave the project in a working * add `attempts.user_id` as a foreign key to `users`, through a reviewed Alembic migration * migrate existing attempts to the owner's account explicitly. Do not silently delete or orphan them. + * Done by making `attempts.user_id` nullable: pre-account attempts stay ownerless and hidden from everyone until `python -m scripts.claim_attempts ` assigns them. Milestone 4 makes the column NOT NULL. * record the current user on every new attempt * read attempt history for the current user only @@ -1485,6 +1486,7 @@ The work involved: * secrets supplied through environment variables; secure, SameSite session cookies * rate limiting on login and registration * scheduled `pg_dump` backups of attempt data, with a restore tested at least once +* make `attempts.user_id` NOT NULL through a migration. Milestone 2 left it nullable so pre-account attempts could stay ownerless until claimed with `scripts.claim_attempts`. The migration should fail loudly if any ownerless attempts remain. * deployment from `main` through GitHub Actions * choosing a host and a domain. Free tiers such as Oracle Cloud Always Free may reclaim idle instances; a small paid VPS is more predictable. * setup documentation in the README diff --git a/README.md b/README.md index 59fd416..9d8c75a 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,14 @@ Requires [Docker](https://www.docker.com/) (Docker Desktop on Windows or macOS). To open the app on a phone on the same network, use `http://:8080`. On Windows, find the IP with `ipconfig` under the Ethernet or Wi-Fi adapter, not the `vEthernet (WSL)` one. +Each account sees only its own attempts. Attempts recorded before accounts existed (V1 and V2) have no owner and are hidden until you assign them. After registering, run this once: + +```bash +docker compose exec backend python -m scripts.claim_attempts +``` + +It only assigns attempts that have no owner, so running it again does nothing. + Attempt data is kept in the `postgres-data` Docker volume. `docker compose down` keeps it; `docker compose down -v` deletes it. ### Running the Tests diff --git a/README.zh-CN.md b/README.zh-CN.md index 8b1479c..ed0d5bc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -286,6 +286,14 @@ V1 刻意使用 JSON 持久化,因为初始数据集很小,主要目标是 在同一网络下用手机访问时,打开 `http://<电脑的局域网 IP>:8080`。Windows 上可以用 `ipconfig` 查看 IP,要看 Ethernet 或 Wi-Fi 网卡下面的地址,不是 `vEthernet (WSL)` 那一个。 +每个账号只能看到自己的攻略记录。有账号系统之前(V1 和 V2)记录的尝试没有归属,在你认领之前对所有人都不可见。注册账号后运行一次: + +```bash +docker compose exec backend python -m scripts.claim_attempts <你的用户名> +``` + +这个命令只会认领没有归属的记录,重复运行也不会有影响。 + 攻略数据保存在名为 `postgres-data` 的 Docker 数据卷里。`docker compose down` 会保留数据;`docker compose down -v` 会把数据一起删除。 ### 运行测试 diff --git a/backend/alembic/versions/30b90438d428_add_attempts_user_id.py b/backend/alembic/versions/30b90438d428_add_attempts_user_id.py new file mode 100644 index 0000000..49eaa34 --- /dev/null +++ b/backend/alembic/versions/30b90438d428_add_attempts_user_id.py @@ -0,0 +1,37 @@ +"""add attempts user_id + +Revision ID: 30b90438d428 +Revises: 980c7a879ddb +Create Date: 2026-09-23 20:30:36.048915 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '30b90438d428' +down_revision: Union[str, Sequence[str], None] = '980c7a879ddb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + user_id is nullable: attempts recorded before accounts existed stay + ownerless, and hidden from everyone, until they are assigned explicitly + with `python -m scripts.claim_attempts `. No rows are deleted. + """ + op.add_column('attempts', sa.Column('user_id', sa.Integer(), nullable=True)) + op.create_index('ix_attempts_user_id_boss_id', 'attempts', ['user_id', 'boss_id'], unique=False) + op.create_foreign_key('attempts_user_id_fkey', 'attempts', 'users', ['user_id'], ['id'], ondelete='RESTRICT') + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('attempts_user_id_fkey', 'attempts', type_='foreignkey') + op.drop_index('ix_attempts_user_id_boss_id', table_name='attempts') + op.drop_column('attempts', 'user_id') diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 80861d4..ab2c757 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -4,6 +4,7 @@ CheckConstraint, DateTime, ForeignKey, + Index, Integer, String, Text, @@ -107,10 +108,15 @@ class Attempt(Base): "failure_move_id IS NULL OR failure_category IS NULL", name="ck_attempts_single_failure_cause", ), + # Every attempt query filters by user and boss. + Index("ix_attempts_user_id_boss_id", "user_id", "boss_id"), ) id: Mapped[int] = mapped_column(primary_key=True) boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="RESTRICT"), index=True) + # Null only for attempts recorded before accounts existed. They are visible to + # nobody until `python -m scripts.claim_attempts ` assigns them. + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT")) result: Mapped[str] = mapped_column(String(20)) phase_reached: Mapped[int] = mapped_column(Integer) failure_move_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id", ondelete="RESTRICT")) @@ -119,6 +125,7 @@ class Attempt(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) boss: Mapped[Boss] = relationship() + user: Mapped["User | None"] = relationship() failure_move: Mapped[Move | None] = relationship() diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 12b10bd..2da518e 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -2,16 +2,14 @@ from sqlalchemy.orm import Session from app.auth import get_current_user +from app.db import models from app.db.session import get_db from app.models.analytics import BossAnalytics, ProgressionPoint from app.models.attempt import Attempt, CreateAttemptRequest from app.services import analytics_service, attempt_service, boss_service -# Attempts and analytics require login. Until V3 Milestone 2 gives attempts an -# owner, every logged-in user still shares one attempt history. -router = APIRouter( - prefix="/api/bosses/{boss_id}", tags=["attempts"], dependencies=[Depends(get_current_user)] -) +# Every endpoint requires login and only ever covers the current user's attempts. +router = APIRouter(prefix="/api/bosses/{boss_id}", tags=["attempts"]) def _ensure_boss_exists(db: Session, boss_id: str) -> None: @@ -20,18 +18,23 @@ def _ensure_boss_exists(db: Session, boss_id: str) -> None: @router.post("/attempts", response_model=Attempt, status_code=status.HTTP_201_CREATED) -def create_attempt(boss_id: str, req: CreateAttemptRequest, db: Session = Depends(get_db)): +def create_attempt( + boss_id: str, + req: CreateAttemptRequest, + user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): _ensure_boss_exists(db, boss_id) try: - return attempt_service.create_attempt(db, boss_id, req) + return attempt_service.create_attempt(db, user.id, boss_id, req) except attempt_service.AttemptValidationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/attempts", response_model=list[Attempt]) -def list_attempts(boss_id: str, db: Session = Depends(get_db)): +def list_attempts(boss_id: str, user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): _ensure_boss_exists(db, boss_id) - return attempt_service.get_attempts(db, boss_id) + return attempt_service.get_attempts(db, user.id, boss_id) @router.get("/analytics", response_model=BossAnalytics) @@ -43,13 +46,14 @@ def get_analytics( le=100, description="How many of the most recent attempts to compare against the full history.", ), + user: models.User = Depends(get_current_user), db: Session = Depends(get_db), ): _ensure_boss_exists(db, boss_id) - return analytics_service.compute_analytics(db, boss_id, recent) + return analytics_service.compute_analytics(db, user.id, boss_id, recent) @router.get("/progression", response_model=list[ProgressionPoint]) -def get_progression(boss_id: str, db: Session = Depends(get_db)): +def get_progression(boss_id: str, user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): _ensure_boss_exists(db, boss_id) - return analytics_service.compute_progression(db, boss_id) + return analytics_service.compute_progression(db, user.id, boss_id) diff --git a/backend/app/routers/sekiro.py b/backend/app/routers/sekiro.py index b6df1b1..1123149 100644 --- a/backend/app/routers/sekiro.py +++ b/backend/app/routers/sekiro.py @@ -2,13 +2,14 @@ from sqlalchemy.orm import Session from app.auth import get_current_user +from app.db import models from app.db.session import get_db from app.models.analytics import SekiroAnalytics from app.services import game_analytics_service -router = APIRouter(prefix="/api/sekiro", tags=["sekiro"], dependencies=[Depends(get_current_user)]) +router = APIRouter(prefix="/api/sekiro", tags=["sekiro"]) @router.get("/analytics", response_model=SekiroAnalytics) -def get_sekiro_analytics(db: Session = Depends(get_db)): - return game_analytics_service.compute_sekiro_analytics(db) +def get_sekiro_analytics(user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): + return game_analytics_service.compute_sekiro_analytics(db, user.id) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index b73b53f..8c8356b 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -55,9 +55,11 @@ def progression(attempts: list[Attempt]) -> list[ProgressionPoint]: ] -def compute_analytics(session: Session, boss_id: str, recent_window: int = DEFAULT_RECENT_WINDOW) -> BossAnalytics: - return summarize(attempt_service.get_attempts(session, boss_id), recent_window) +def compute_analytics( + session: Session, user_id: int, boss_id: str, recent_window: int = DEFAULT_RECENT_WINDOW +) -> BossAnalytics: + return summarize(attempt_service.get_attempts(session, user_id, boss_id), recent_window) -def compute_progression(session: Session, boss_id: str) -> list[ProgressionPoint]: - return progression(attempt_service.get_attempts(session, boss_id)) +def compute_progression(session: Session, user_id: int, boss_id: str) -> list[ProgressionPoint]: + return progression(attempt_service.get_attempts(session, user_id, boss_id)) diff --git a/backend/app/services/attempt_service.py b/backend/app/services/attempt_service.py index 8d48488..c45c081 100644 --- a/backend/app/services/attempt_service.py +++ b/backend/app/services/attempt_service.py @@ -23,11 +23,12 @@ def _to_schema(row: db.Attempt, boss_slug: str) -> Attempt: ) -def get_attempts(session: Session, boss_slug: str) -> list[Attempt]: +def get_attempts(session: Session, user_id: int, boss_slug: str) -> list[Attempt]: + """One user's attempts against one boss. Other users' and ownerless attempts are never included.""" rows = session.scalars( select(db.Attempt) .join(db.Boss) - .where(db.Boss.slug == boss_slug) + .where(db.Attempt.user_id == user_id, db.Boss.slug == boss_slug) .options(selectinload(db.Attempt.failure_move)) # Newest first. id breaks ties between attempts created in the same transaction. .order_by(db.Attempt.created_at.desc(), db.Attempt.id.desc()) @@ -35,7 +36,7 @@ def get_attempts(session: Session, boss_slug: str) -> list[Attempt]: return [_to_schema(row, boss_slug) for row in rows] -def create_attempt(session: Session, boss_slug: str, req: CreateAttemptRequest) -> Attempt: +def create_attempt(session: Session, user_id: int, boss_slug: str, req: CreateAttemptRequest) -> Attempt: boss = boss_service.get_boss_row(session, boss_slug) if boss is None: raise AttemptValidationError(f"Boss '{boss_slug}' not found") @@ -70,6 +71,7 @@ def create_attempt(session: Session, boss_slug: str, req: CreateAttemptRequest) row = db.Attempt( boss=boss, + user_id=user_id, result=req.result.value, phase_reached=phase_reached, failure_move=failure_move, diff --git a/backend/app/services/game_analytics_service.py b/backend/app/services/game_analytics_service.py index b5e0c64..635fba4 100644 --- a/backend/app/services/game_analytics_service.py +++ b/backend/app/services/game_analytics_service.py @@ -1,4 +1,4 @@ -"""Game-level analytics across every Sekiro boss.""" +"""Game-level analytics across every Sekiro boss, for one user's attempts.""" from datetime import datetime, timedelta, timezone @@ -13,7 +13,7 @@ RECENT_ATTEMPTS_LIMIT = 10 -def _boss_rows(session: Session) -> list[BossComparisonRow]: +def _boss_rows(session: Session, user_id: int) -> list[BossComparisonRow]: """One row per boss, computed with grouped queries rather than a query per boss.""" phase_counts = ( select(db.BossPhase.boss_id, func.count().label("total_phases")).group_by(db.BossPhase.boss_id).subquery() @@ -26,17 +26,22 @@ def _boss_rows(session: Session) -> list[BossComparisonRow]: func.bool_or(db.Attempt.result == AttemptResult.VICTORY.value).label("defeated"), func.max(db.Attempt.created_at).label("last_attempt_at"), ) + .where(db.Attempt.user_id == user_id) .group_by(db.Attempt.boss_id) .subquery() ) # Number each boss's attempts chronologically, then take the number of its first victory. - numbered = select( - db.Attempt.boss_id, - db.Attempt.result, - func.row_number() - .over(partition_by=db.Attempt.boss_id, order_by=(db.Attempt.created_at, db.Attempt.id)) - .label("attempt_number"), - ).subquery() + numbered = ( + select( + db.Attempt.boss_id, + db.Attempt.result, + func.row_number() + .over(partition_by=db.Attempt.boss_id, order_by=(db.Attempt.created_at, db.Attempt.id)) + .label("attempt_number"), + ) + .where(db.Attempt.user_id == user_id) + .subquery() + ) first_victory = ( select( numbered.c.boss_id, @@ -81,11 +86,12 @@ def _boss_rows(session: Session) -> list[BossComparisonRow]: ] -def _recent_attempts(session: Session) -> list[RecentAttempt]: +def _recent_attempts(session: Session, user_id: int) -> list[RecentAttempt]: rows = session.execute( select(db.Attempt, db.Boss.slug, db.Boss.name, db.Move.slug, db.Move.name) .join(db.Boss, db.Attempt.boss_id == db.Boss.id) .outerjoin(db.Move, db.Attempt.failure_move_id == db.Move.id) + .where(db.Attempt.user_id == user_id) .order_by(db.Attempt.created_at.desc(), db.Attempt.id.desc()) .limit(RECENT_ATTEMPTS_LIMIT) ) @@ -113,15 +119,15 @@ def _ids_with_max(rows: list[BossComparisonRow], value) -> list[str]: return [r.id for r in rows if value(r) == top] -def compute_sekiro_analytics(session: Session, now: datetime | None = None) -> SekiroAnalytics: +def compute_sekiro_analytics(session: Session, user_id: int, now: datetime | None = None) -> SekiroAnalytics: now = now or datetime.now(timezone.utc) - bosses = _boss_rows(session) + bosses = _boss_rows(session, user_id) attempted = [b for b in bosses if b.attempts > 0] defeated = [b for b in bosses if b.defeated] in_window = session.scalar( select(func.count()) .select_from(db.Attempt) - .where(db.Attempt.created_at >= now - timedelta(days=RECENT_WINDOW_DAYS)) + .where(db.Attempt.user_id == user_id, db.Attempt.created_at >= now - timedelta(days=RECENT_WINDOW_DAYS)) ) return SekiroAnalytics( @@ -134,6 +140,6 @@ def compute_sekiro_analytics(session: Session, now: datetime | None = None) -> S bosses_requiring_most_attempts=_ids_with_max(defeated, lambda b: b.attempts_until_first_victory), recent_window_days=RECENT_WINDOW_DAYS, attempts_in_recent_window=in_window, - recent_attempts=_recent_attempts(session), + recent_attempts=_recent_attempts(session, user_id), bosses=bosses, ) diff --git a/backend/scripts/claim_attempts.py b/backend/scripts/claim_attempts.py new file mode 100644 index 0000000..67d2ed6 --- /dev/null +++ b/backend/scripts/claim_attempts.py @@ -0,0 +1,57 @@ +"""Assign ownerless attempts to a user. + +Attempts recorded before accounts existed (V1/V2 history) have no owner and +are visible to nobody. Run this once after registering your account: + + python -m scripts.claim_attempts + +Only ownerless attempts are touched; attempts that already belong to someone +are never reassigned. Running it again is harmless. +""" + +import argparse +import sys + +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session + +from app.db.models import Attempt, User +from app.db.session import get_sessionmaker +from app.services.auth_service import normalize_username + + +class ClaimError(Exception): + pass + + +def claim_attempts(session: Session, username: str) -> int: + """Gives every ownerless attempt to `username`; returns how many were claimed.""" + user = session.scalar(select(User).where(User.username == normalize_username(username))) + if user is None: + raise ClaimError(f"No user named '{username}'. Register the account in the app first.") + result = session.execute(update(Attempt).where(Attempt.user_id.is_(None)).values(user_id=user.id)) + session.commit() + return result.rowcount + + +def count_ownerless(session: Session) -> int: + return session.scalar(select(func.count()).select_from(Attempt).where(Attempt.user_id.is_(None))) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("username", help="the account that should own the ownerless attempts") + args = parser.parse_args(argv) + + with get_sessionmaker()() as session: + try: + claimed = claim_attempts(session, args.username) + except ClaimError as exc: + print(exc, file=sys.stderr) + return 1 + print(f"Assigned {claimed} ownerless attempt(s) to '{normalize_username(args.username)}'.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d501cb0..e817fe3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,6 +5,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session +from app.db.models import User from app.db.session import get_db from app.main import app from app.seed import load_boss_data, sync_reference_data @@ -48,6 +49,19 @@ def seeded_session(session): return session +@pytest.fixture() +def make_user(seeded_session): + """Creates a user row directly, for service-level tests that don't go through the API.""" + + def make(username: str) -> User: + user = User(username=username, password_hash="not-a-real-hash") + seeded_session.add(user) + seeded_session.flush() + return user + + return make + + @pytest.fixture() def anon_client(seeded_session): """A client that is not logged in.""" diff --git a/backend/tests/db/test_schema.py b/backend/tests/db/test_schema.py index 0f9c6e1..cd8fb71 100644 --- a/backend/tests/db/test_schema.py +++ b/backend/tests/db/test_schema.py @@ -50,6 +50,34 @@ def test_migration_can_downgrade_and_upgrade_again(engine, database_url): assert APP_TABLES <= set(inspect(engine).get_table_names()) +def test_adding_attempt_owners_keeps_existing_attempts_as_ownerless(engine, database_url): + """Upgrading a database that already has V2 attempts must not drop or reassign them.""" + config = alembic_config(database_url) + command.downgrade(config, "980c7a879ddb") # the revision before attempts.user_id + try: + with engine.begin() as conn: + game_id = conn.scalar(text("INSERT INTO games (slug, name) VALUES ('mig', 'Mig') RETURNING id")) + boss_id = conn.scalar( + text("INSERT INTO bosses (game_id, slug, name, location) VALUES (:g, 'mig-boss', 'M', 'L') RETURNING id"), + {"g": game_id}, + ) + conn.execute( + text("INSERT INTO attempts (boss_id, result, phase_reached) VALUES (:b, 'failed', 1)"), {"b": boss_id} + ) + + command.upgrade(config, "head") + + with engine.connect() as conn: + rows = conn.execute(text("SELECT user_id FROM attempts WHERE boss_id = :b"), {"b": boss_id}).all() + assert rows == [(None,)] + finally: + command.upgrade(config, "head") + with engine.begin() as conn: + conn.execute(text("DELETE FROM attempts WHERE boss_id IN (SELECT id FROM bosses WHERE slug = 'mig-boss')")) + conn.execute(text("DELETE FROM bosses WHERE slug = 'mig-boss'")) + conn.execute(text("DELETE FROM games WHERE slug = 'mig'")) + + def test_move_is_stored_once_and_shared_across_phases(session): boss = make_boss(session) phase_1 = BossPhase(boss=boss, phase_number=1, name="Phase 1") diff --git a/backend/tests/test_sekiro_analytics.py b/backend/tests/test_sekiro_analytics.py index 0d4582d..6d7192a 100644 --- a/backend/tests/test_sekiro_analytics.py +++ b/backend/tests/test_sekiro_analytics.py @@ -103,23 +103,27 @@ def test_recent_attempts_are_newest_first_with_names_and_capped_at_ten(client): assert [int(a["attempt_id"]) for a in recent] == sorted((int(a["attempt_id"]) for a in recent), reverse=True) -def test_counts_only_attempts_from_the_last_seven_days(seeded_session): +def test_counts_only_attempts_from_the_last_seven_days(seeded_session, make_user): now = datetime(2026, 9, 22, 12, tzinfo=timezone.utc) + user = make_user("wolf") owl = seeded_session.scalar(select(db.Boss).where(db.Boss.slug == "owl-father")) for days_ago in (0, 3, 6.9, 7.1, 30): seeded_session.add( - db.Attempt(boss=owl, result="failed", phase_reached=1, created_at=now - timedelta(days=days_ago)) + db.Attempt( + boss=owl, user=user, result="failed", phase_reached=1, created_at=now - timedelta(days=days_ago) + ) ) seeded_session.flush() - data = compute_sekiro_analytics(seeded_session, now=now) + data = compute_sekiro_analytics(seeded_session, user.id, now=now) assert data.recent_window_days == 7 assert data.attempts_in_recent_window == 3 assert data.total_attempts == 5 -def test_uses_a_fixed_number_of_queries_regardless_of_boss_count(seeded_session): +def test_uses_a_fixed_number_of_queries_regardless_of_boss_count(seeded_session, make_user): + user = make_user("wolf") statements = [] def listener(conn, cursor, statement, parameters, context, executemany): @@ -128,7 +132,7 @@ def listener(conn, cursor, statement, parameters, context, executemany): connection = seeded_session.connection() event.listen(connection, "before_cursor_execute", listener) try: - compute_sekiro_analytics(seeded_session) + compute_sekiro_analytics(seeded_session, user.id) finally: event.remove(connection, "before_cursor_execute", listener) diff --git a/backend/tests/test_user_isolation.py b/backend/tests/test_user_isolation.py new file mode 100644 index 0000000..16ceb3e --- /dev/null +++ b/backend/tests/test_user_isolation.py @@ -0,0 +1,168 @@ +"""Every attempt belongs to one user, and nobody sees anyone else's (V3 Milestones 2-3).""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.db import models as db +from app.main import app +from app.services import analytics_service, attempt_service +from app.services.game_analytics_service import compute_sekiro_analytics +from scripts.claim_attempts import ClaimError, claim_attempts, count_ownerless + +BOSS = "genichiro-ashina" + + +def record(client, **fields): + body = {"result": "failed", "phase_reached": 1, "failure_move_id": None, "failure_category": None, **fields} + resp = client.post(f"/api/bosses/{BOSS}/attempts", json=body) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.fixture() +def other_client(anon_client): + """A second logged-in user with its own cookie jar, sharing the test database.""" + other = TestClient(app) + resp = other.post("/api/auth/register", json={"username": "other", "password": "correct-horse"}) + assert resp.status_code == 201, resp.text + return other + + +def boss_row(session, slug=BOSS): + return session.scalar(select(db.Boss).where(db.Boss.slug == slug)) + + +def add_ownerless_attempt(session, **fields): + attempt = db.Attempt(boss=boss_row(session), **{"result": "failed", "phase_reached": 1, **fields}) + session.add(attempt) + session.flush() + return attempt + + +# --- API level --------------------------------------------------------------- + + +def test_attempt_history_is_per_user(client, other_client): + mine = [record(client), record(client, phase_reached=2)] + theirs = record(other_client, phase_reached=2, failure_move_id="floating-passage") + + my_history = client.get(f"/api/bosses/{BOSS}/attempts").json() + their_history = other_client.get(f"/api/bosses/{BOSS}/attempts").json() + + assert {a["id"] for a in my_history} == {a["id"] for a in mine} + assert [a["id"] for a in their_history] == [theirs["id"]] + + +def test_boss_analytics_and_progression_are_per_user(client, other_client): + record(client, phase_reached=1) + record(client, phase_reached=1) + record(other_client, phase_reached=2, failure_move_id="floating-passage") + + mine = client.get(f"/api/bosses/{BOSS}/analytics").json() + theirs = other_client.get(f"/api/bosses/{BOSS}/analytics").json() + + assert mine["total_attempts"] == 2 + assert mine["failure_by_phase"] == {"1": 2} + assert mine["failure_by_move"] == {} + assert theirs["total_attempts"] == 1 + assert theirs["most_common_failure_move"] == "floating-passage" + assert len(client.get(f"/api/bosses/{BOSS}/progression").json()) == 2 + assert len(other_client.get(f"/api/bosses/{BOSS}/progression").json()) == 1 + + +def test_sekiro_dashboard_is_per_user(client, other_client): + mine = record(client) + record(other_client) + record(other_client) + other_client.post("/api/bosses/guardian-ape/attempts", json={"result": "victory"}) + + my_dashboard = client.get("/api/sekiro/analytics").json() + their_dashboard = other_client.get("/api/sekiro/analytics").json() + + assert my_dashboard["total_attempts"] == 1 + assert my_dashboard["bosses_attempted"] == 1 + assert my_dashboard["bosses_defeated"] == 0 + assert my_dashboard["attempts_in_recent_window"] == 1 + assert [a["attempt_id"] for a in my_dashboard["recent_attempts"]] == [mine["id"]] + assert their_dashboard["total_attempts"] == 3 + assert their_dashboard["bosses_defeated"] == 1 + + +def test_a_user_id_in_the_request_body_is_ignored(client, other_client, seeded_session): + me = seeded_session.scalar(select(db.User).where(db.User.username == "tester")) + + created = record(other_client, user_id=me.id) + + assert client.get(f"/api/bosses/{BOSS}/attempts").json() == [] + assert seeded_session.get(db.Attempt, int(created["id"])).user.username == "other" + + +def test_ownerless_attempts_are_visible_to_nobody(client, seeded_session): + add_ownerless_attempt(seeded_session) + + assert client.get(f"/api/bosses/{BOSS}/attempts").json() == [] + assert client.get(f"/api/bosses/{BOSS}/analytics").json()["total_attempts"] == 0 + assert client.get("/api/sekiro/analytics").json()["total_attempts"] == 0 + + +# --- Service level ----------------------------------------------------------- + + +def test_services_only_return_the_given_users_attempts(seeded_session, make_user): + wolf, emma = make_user("wolf"), make_user("emma") + boss = boss_row(seeded_session) + seeded_session.add_all( + [ + db.Attempt(boss=boss, user=wolf, result="failed", phase_reached=1), + db.Attempt(boss=boss, user=emma, result="failed", phase_reached=3), + ] + ) + add_ownerless_attempt(seeded_session, phase_reached=2) + + assert [a.phase_reached for a in attempt_service.get_attempts(seeded_session, wolf.id, BOSS)] == [1] + assert analytics_service.compute_analytics(seeded_session, emma.id, BOSS).best_phase == 3 + assert [p.phase_reached for p in analytics_service.compute_progression(seeded_session, wolf.id, BOSS)] == [1] + + +def test_first_victory_is_counted_within_each_users_own_history(seeded_session, make_user): + wolf, emma = make_user("wolf"), make_user("emma") + boss = boss_row(seeded_session) + # Interleaved: emma's win must not shift wolf's attempt numbering, or vice versa. + for user, result in [(wolf, "failed"), (emma, "victory"), (wolf, "failed"), (wolf, "victory")]: + seeded_session.add(db.Attempt(boss=boss, user=user, result=result, phase_reached=3)) + seeded_session.flush() + + wolf_row = next(b for b in compute_sekiro_analytics(seeded_session, wolf.id).bosses if b.id == BOSS) + emma_row = next(b for b in compute_sekiro_analytics(seeded_session, emma.id).bosses if b.id == BOSS) + + assert (wolf_row.attempts, wolf_row.attempts_until_first_victory) == (3, 3) + assert (emma_row.attempts, emma_row.attempts_until_first_victory) == (1, 1) + + +# --- Claiming ownerless attempts --------------------------------------------- + + +def test_claim_assigns_only_ownerless_attempts(seeded_session, make_user): + owner, other = make_user("owner"), make_user("other") + old = [add_ownerless_attempt(seeded_session), add_ownerless_attempt(seeded_session, phase_reached=2)] + theirs = db.Attempt(boss=boss_row(seeded_session), user=other, result="failed", phase_reached=1) + seeded_session.add(theirs) + seeded_session.flush() + + claimed = claim_attempts(seeded_session, "OWNER") + + assert claimed == 2 + assert all(a.user_id == owner.id for a in old) + assert theirs.user_id == other.id + assert count_ownerless(seeded_session) == 0 + assert claim_attempts(seeded_session, "owner") == 0 + + +def test_claim_refuses_an_unknown_user(seeded_session): + add_ownerless_attempt(seeded_session) + + with pytest.raises(ClaimError): + claim_attempts(seeded_session, "nobody") + + assert count_ownerless(seeded_session) == 1 diff --git a/frontend/integration-tests/attemptWorkflow.test.ts b/frontend/integration-tests/attemptWorkflow.test.ts index a126123..2031e0e 100644 --- a/frontend/integration-tests/attemptWorkflow.test.ts +++ b/frontend/integration-tests/attemptWorkflow.test.ts @@ -255,6 +255,41 @@ describe("frontend calling the real backend", () => { }); }); + it("gives a second user an empty history that stays separate from the first", async () => { + const { register } = await import("../src/api/auth"); + const { createAttempt, getBossAnalytics, getBossAttempts } = await import("../src/api/attempts"); + const { getSekiroAnalytics } = await import("../src/api/sekiro"); + + // Act as a different browser: set the first user's session aside. + const firstUserCookies = new Map(cookieJar); + cookieJar.clear(); + try { + await register({ username: "integration_emma", password: "kusabimaru" }); + + expect(await getBossAttempts("genichiro-ashina")).toEqual([]); + expect((await getSekiroAnalytics()).total_attempts).toBe(0); + + await createAttempt("genichiro-ashina", { + result: "victory", + phase_reached: null, + failure_move_id: null, + failure_category: null, + notes: "", + }); + const analytics = await getBossAnalytics("genichiro-ashina"); + expect(analytics.total_attempts).toBe(1); + expect(analytics.attempts_until_first_victory).toBe(1); + } finally { + cookieJar.clear(); + for (const [name, value] of firstUserCookies) cookieJar.set(name, value); + } + + // The first user's numbers are unchanged by the second user's victory. + const analytics = await getBossAnalytics("genichiro-ashina"); + expect(analytics.total_attempts).toBe(2); + expect(analytics.defeated).toBe(false); + }); + it("logs out and loses access to attempts again", async () => { const { getCurrentUser, login, logout } = await import("../src/api/auth"); const { getBossAttempts } = await import("../src/api/attempts"); From 843841fccd5db487872acdd84b4cb3f7fbed3e8a Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 22:00:57 -0400 Subject: [PATCH 04/19] Record V3 Milestones 1-3 as completed in CLAUDE.md and ROADMAP Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 16 +++++++++++++--- ROADMAP.md | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 013ebc4..a8dd847 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,16 @@ Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) Released as `v2.0.0`. +## V3 — In Progress + +Completed so far: + +* **Milestone 1 — Accounts and Authentication:** `users` and `user_sessions` tables, argon2 password hashing, server-side sessions in an httpOnly SameSite=Lax cookie (only a SHA-256 hash of the token is stored), register / login / logout / me endpoints, login and register pages, and a welcome page with the boss list for visitors. Registration is open to everyone. +* **Milestone 2 — User-Owned Attempts:** `attempts.user_id` foreign key. It is nullable for now: attempts from before accounts existed stay ownerless and hidden until `python -m scripts.claim_attempts ` assigns them. +* **Milestone 3 — Per-User Analytics and Isolation:** attempt history, boss analytics, progression and the Sekiro dashboard only cover the logged-in user, with isolation tests at the API and service level. + +Next: Milestone 4 (public deployment), which also makes `attempts.user_id` NOT NULL. + --- # Current Development Focus: V3 — Personalized Practice Coach @@ -119,7 +129,7 @@ Do NOT introduce the following in V3 unless explicitly requested: Implement V3 incrementally. Each milestone should leave the project in a working state. -### Milestone 1 — Accounts and Authentication +### Milestone 1 — Accounts and Authentication (Completed) * add a `users` table through an Alembic migration * hash passwords with argon2 @@ -128,7 +138,7 @@ Implement V3 incrementally. Each milestone should leave the project in a working * add a login / register page in the frontend * boss data remains browsable without logging in; recording attempts and viewing analytics require login -### Milestone 2 — User-Owned Attempts +### Milestone 2 — User-Owned Attempts (Completed) * add `attempts.user_id` as a foreign key to `users`, through a reviewed Alembic migration * migrate existing attempts to the owner's account explicitly. Do not silently delete or orphan them. @@ -136,7 +146,7 @@ Implement V3 incrementally. Each milestone should leave the project in a working * record the current user on every new attempt * read attempt history for the current user only -### Milestone 3 — Per-User Analytics and Isolation +### Milestone 3 — Per-User Analytics and Isolation (Completed) * scope boss analytics, progression and the Sekiro dashboard to the current user * add tests proving users cannot read or write each other's attempts, at both the API and service level diff --git a/ROADMAP.md b/ROADMAP.md index fa79074..3d4a9f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,9 +87,9 @@ V3 introduces individual player identity, public deployment and practice recomme Milestones: -1. **Accounts and authentication:** register, log in, log out, with hashed passwords and session cookies -2. **User-owned attempts:** every attempt belongs to a user; existing attempts move to the owner's account -3. **Per-user analytics:** boss analytics, progression and the Sekiro dashboard show only the user's own data +1. ✅ **Accounts and authentication:** register, log in, log out, with hashed passwords and session cookies +2. ✅ **User-owned attempts:** every attempt belongs to a user; existing attempts move to the owner's account +3. ✅ **Per-user analytics:** boss analytics, progression and the Sekiro dashboard show only the user's own data 4. **Public deployment:** a stable URL with HTTPS, backups and deployment from `main` 5. **Practice recommendations:** rule-based suggestions derived from the user's own attempts, each showing its evidence 6. **Integration testing, CI and release** From c04234ae13d31c089dd5e365183ee9960bf08820 Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 22:14:55 -0400 Subject: [PATCH 05/19] Plan V3 content completion before public deployment Insert Milestone 4 (all main bosses, Chinese move names from a Chinese wiki or marked as translations) before public deployment, which becomes Milestone 5. Move video references are a deferred Milestone 8. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 60 +++++++++++++++++++++++++++++++-------------- ROADMAP.md | 10 +++++--- backend/app/auth.py | 2 +- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8dd847..08f5442 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ V2 delivered the structured, multi-boss Sekiro analytics application: * four required CI jobs: backend, frontend, integration, docker * local deployment with Docker Compose -Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) moved into V3 as Milestone 4. +Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) moved into V3 as Milestone 5. Released as `v2.0.0`. @@ -74,7 +74,7 @@ Completed so far: * **Milestone 2 — User-Owned Attempts:** `attempts.user_id` foreign key. It is nullable for now: attempts from before accounts existed stay ownerless and hidden until `python -m scripts.claim_attempts ` assigns them. * **Milestone 3 — Per-User Analytics and Isolation:** attempt history, boss analytics, progression and the Sekiro dashboard only cover the logged-in user, with isolation tests at the API and service level. -Next: Milestone 4 (public deployment), which also makes `attempts.user_id` NOT NULL. +Next: Milestone 4 (content completion: the remaining main bosses and Chinese move names), then Milestone 5 (public deployment), which also makes `attempts.user_id` NOT NULL. --- @@ -84,16 +84,20 @@ V3 answers: > **What should I practice next?** -V3 scope is exactly three capabilities: +V3 scope is: ```text User accounts + +Content completion (all main bosses, Chinese move names) ++ Public deployment + Rule-based practice recommendations ``` +Content completion was added after Milestones 1–3, so the public release covers every main boss and shows moves in Chinese as well as English. Move video or GIF references were considered and deferred (Milestone 8). + Long-term tracking and practice goals are NOT part of V3. Long-term progression is already covered by V2's progression chart, recent vs. all-time comparisons and Sekiro dashboard. Practice goals were considered and dropped. ## Precedence @@ -142,7 +146,7 @@ Implement V3 incrementally. Each milestone should leave the project in a working * add `attempts.user_id` as a foreign key to `users`, through a reviewed Alembic migration * migrate existing attempts to the owner's account explicitly. Do not silently delete or orphan them. - * Done by making `attempts.user_id` nullable: pre-account attempts stay ownerless and hidden from everyone until `python -m scripts.claim_attempts ` assigns them. Milestone 4 makes the column NOT NULL. + * Done by making `attempts.user_id` nullable: pre-account attempts stay ownerless and hidden from everyone until `python -m scripts.claim_attempts ` assigns them. Milestone 5 makes the column NOT NULL. * record the current user on every new attempt * read attempt history for the current user only @@ -152,11 +156,22 @@ Implement V3 incrementally. Each milestone should leave the project in a working * add tests proving users cannot read or write each other's attempts, at both the API and service level * keep the V2 analytics definitions unchanged apart from the user scope -### Milestone 4 — Public Deployment +### Milestone 4 — Content Completion + +Comes before public deployment so the public release is complete. + +* add the remaining **main** bosses (mini-bosses stay out of scope), following the existing data rules: sourced from a wiki, boss-level `source_name` / `source_url`, `telegraph` and `common_mistakes` only when the source states them, reviewed by the user +* add a Chinese name to every move: + * prefer the name used by a Chinese Sekiro wiki (option A), recorded with its source + * where no Chinese wiki name exists, provide a translation (option B), stored and shown as a translation, never presented as an official name + * the user reviews all Chinese names manually +* keep boss data quality over quantity; do not create placeholder moves + +### Milestone 5 — Public Deployment -Moved from V2 Milestone 10. It comes after Milestones 1–3 because accounts are what make a public instance safe. See "Public Deployment" below for the work involved. +Moved from V2 Milestone 10. It comes after the account milestones because accounts are what make a public instance safe, and after content completion so the public release is complete. See "Public Deployment" below for the work involved. -### Milestone 5 — Practice Recommendations +### Milestone 6 — Practice Recommendations * rule-based and explainable, derived only from the user's attempt records * candidate signals include: @@ -167,13 +182,21 @@ Moved from V2 Milestone 10. It comes after Milestones 1–3 because accounts are * failure counts for a move only count attempts that reached a phase containing that move, so dying earlier cannot make a move look "improved" * the exact rules are decided with the user before implementation -### Milestone 6 — Integration Testing, CI and Release +### Milestone 7 — Integration Testing, CI and Release * an end-to-end test: register → record attempts → analytics → recommendations * extend CI to cover authentication and user isolation * verify the production deployment workflow * release `v3.0.0` +### Milestone 8 — Move Video References (Deferred) + +Short clips or GIFs showing each move, so players can identify it more easily. + +Deferred because of the effort (clips for every move), hosting size, and copyright: wiki-hosted GIFs must not be hotlinked or copied, and game footage belongs to FromSoftware. A lighter option to consider first is a timestamped YouTube link per move. + +Not required for `v3.0.0`. + ## V3 Success Criteria V3 is complete when a user can: @@ -251,7 +274,7 @@ V2 should preserve that functionality while adding: * recent vs historical comparisons * overall Sekiro-level analytics * stronger integration testing -* a reliable local deployment with Docker Compose (public hosting moved into V3 Milestone 4) +* a reliable local deployment with Docker Compose (public hosting moved into V3 Milestone 5) --- @@ -394,7 +417,7 @@ These belong to V3–V5 or should only be introduced when justified by an actual * Docker Compose, run locally -V2 has no public hosting and no staging or production environments. Public deployment moved into V3 Milestone 4; see "Public Deployment (V3 Milestone 4)". +V2 has no public hosting and no staging or production environments. Public deployment moved into V3 Milestone 5; see "Public Deployment (V3 Milestone 5)". --- @@ -556,7 +579,7 @@ source_name source_url ``` -`name_zh` is the official Simplified Chinese name. Only boss names are localized; move names and the UI stay in English for now. +`name_zh` is the official Simplified Chinese name. Boss names and (from V3 Milestone 4) move names have Chinese versions; the rest of the UI stays in English for now. `source_name` and `source_url` record where the boss's phase and move data came from. All moves of a boss share this source. @@ -1480,14 +1503,14 @@ v2.0.0 (run locally with Docker Compose) --- -# Public Deployment (V3 Milestone 4) +# Public Deployment (V3 Milestone 5) V2 ran locally only. Public deployment was moved into V3 because: * V2 has no authentication, so a public instance would let anyone record attempts into the only attempt history * V3 Milestones 1–3 add user accounts and per-user data, which solves that problem directly -Until Milestone 4 is done, a Cloudflare Tunnel to the local instance is acceptable for temporary remote access, such as from a phone away from home. +Until Milestone 5 is done, a Cloudflare Tunnel to the local instance is acceptable for temporary remote access, such as from a phone away from home. The work involved: @@ -1774,11 +1797,11 @@ A broken persistence or analytics change should normally be detected before merg --- -## Milestone 10 — Stable Deployment (Moved to V3 Milestone 4) +## Milestone 10 — Stable Deployment (Moved to V3 Milestone 5) ### Decision -Public deployment moved into V3 as Milestone 4. See "Public Deployment (V3 Milestone 4)" for the reasons and the work involved. +Public deployment moved into V3 as Milestone 5. See "Public Deployment (V3 Milestone 5)" for the reasons and the work involved. ### What V2 Still Includes @@ -1821,7 +1844,7 @@ Recommended sequence: 13. CI Hardening -14. Stable Deployment (moved to V3 Milestone 4, see Milestone 10) +14. Stable Deployment (moved to V3 Milestone 5, see Milestone 10) 15. Release v2.0.0 ``` @@ -1927,7 +1950,7 @@ These are NOT V3 requirements. When working in this repository: 1. Treat V1 and V2 as completed. -2. Treat V3 as the current active development scope, limited to accounts, public deployment and rule-based practice recommendations. +2. Treat V3 as the current active development scope, limited to accounts, content completion, public deployment and rule-based practice recommendations. 3. Preserve the lightweight manual attempt-recording workflow; accounts must not add fields to it. 4. Do not add detailed manual combat telemetry. 5. Continue supporting `Other` and `Not Sure`. @@ -1945,7 +1968,7 @@ When working in this repository: 17. Keep database access and analytics logic outside route handlers. 18. Avoid unnecessary repository/factory/framework abstractions. 19. Do not create large amounts of low-quality boss data. -20. Do not invent Sekiro mechanics when uncertain, and keep game knowledge traceable to sources. +20. Do not invent Sekiro mechanics when uncertain, and keep game knowledge traceable to sources. Chinese move names come from a Chinese wiki where one exists; otherwise they are marked as translations. 21. Do not commit secrets, production credentials or private database URLs. 22. Do not add OAuth, email infrastructure, roles, social features or practice goals in V3. 23. Do not add video analysis (V4) or Black Myth: Wukong (V5). @@ -1966,6 +1989,7 @@ If the feature does not clearly belong to: Accounts and Authentication User-Owned Attempts Per-User Analytics and Isolation +Content Completion Public Deployment Practice Recommendations Testing / CI / Release diff --git a/ROADMAP.md b/ROADMAP.md index 3d4a9f9..c077a5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,16 +83,18 @@ Released as `v2.0.0`. **Core question:** **What should I practice next?** -V3 introduces individual player identity, public deployment and practice recommendations. +V3 introduces individual player identity, complete boss content, public deployment and practice recommendations. Milestones: 1. ✅ **Accounts and authentication:** register, log in, log out, with hashed passwords and session cookies 2. ✅ **User-owned attempts:** every attempt belongs to a user; existing attempts move to the owner's account 3. ✅ **Per-user analytics:** boss analytics, progression and the Sekiro dashboard show only the user's own data -4. **Public deployment:** a stable URL with HTTPS, backups and deployment from `main` -5. **Practice recommendations:** rule-based suggestions derived from the user's own attempts, each showing its evidence -6. **Integration testing, CI and release** +4. **Content completion:** every main boss, and a Chinese name for every move (from a Chinese wiki where one exists, otherwise marked as a translation) +5. **Public deployment:** a stable URL with HTTPS, backups and deployment from `main` +6. **Practice recommendations:** rule-based suggestions derived from the user's own attempts, each showing its evidence +7. **Integration testing, CI and release** +8. **Move video references (deferred):** short clips showing each move; not required for `v3.0.0` Example output: diff --git a/backend/app/auth.py b/backend/app/auth.py index 94d8ab5..51baddf 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -14,7 +14,7 @@ def _cookie_secure() -> bool: # Local development runs over plain HTTP, so Secure is opt-in until the - # HTTPS deployment (V3 Milestone 4) sets COOKIE_SECURE=true. + # HTTPS deployment (V3 Milestone 5) sets COOKIE_SECURE=true. return os.environ.get("COOKIE_SECURE", "false").lower() == "true" From f8deb6e547484a40155df3475046ba7aaf7c1ec3 Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 23:10:38 -0400 Subject: [PATCH 06/19] Plan V3 bilingual interface and moveset review Milestone 4 becomes the English/Chinese interface (language saved in the account, or the browser for visitors; each language shows only its own text). Milestone 5 is content completion plus a review of existing movesets. Deployment, recommendations, release and video references move to Milestones 6-9. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 74 ++++++++++++++++++++++++++++----------------- ROADMAP.md | 13 ++++---- backend/app/auth.py | 2 +- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08f5442..3591f6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ V2 delivered the structured, multi-boss Sekiro analytics application: * four required CI jobs: backend, frontend, integration, docker * local deployment with Docker Compose -Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) moved into V3 as Milestone 5. +Milestone 8 (search and filtering) was skipped. Milestone 10 (public deployment) moved into V3 as Milestone 6. Released as `v2.0.0`. @@ -74,7 +74,7 @@ Completed so far: * **Milestone 2 — User-Owned Attempts:** `attempts.user_id` foreign key. It is nullable for now: attempts from before accounts existed stay ownerless and hidden until `python -m scripts.claim_attempts ` assigns them. * **Milestone 3 — Per-User Analytics and Isolation:** attempt history, boss analytics, progression and the Sekiro dashboard only cover the logged-in user, with isolation tests at the API and service level. -Next: Milestone 4 (content completion: the remaining main bosses and Chinese move names), then Milestone 5 (public deployment), which also makes `attempts.user_id` NOT NULL. +Next: Milestone 4 (bilingual interface), then Milestone 5 (content completion and moveset review), then Milestone 6 (public deployment), which also makes `attempts.user_id` NOT NULL. --- @@ -89,14 +89,16 @@ V3 scope is: ```text User accounts + -Content completion (all main bosses, Chinese move names) +Bilingual interface (English / 中文) ++ +Content completion (all main bosses, reviewed movesets) + Public deployment + Rule-based practice recommendations ``` -Content completion was added after Milestones 1–3, so the public release covers every main boss and shows moves in Chinese as well as English. Move video or GIF references were considered and deferred (Milestone 8). +The bilingual interface and content completion were added after Milestones 1–3, so the public release covers every main boss in both English and Chinese. Move video or GIF references were considered and deferred (Milestone 9). Long-term tracking and practice goals are NOT part of V3. Long-term progression is already covered by V2's progression chart, recent vs. all-time comparisons and Sekiro dashboard. Practice goals were considered and dropped. @@ -146,7 +148,7 @@ Implement V3 incrementally. Each milestone should leave the project in a working * add `attempts.user_id` as a foreign key to `users`, through a reviewed Alembic migration * migrate existing attempts to the owner's account explicitly. Do not silently delete or orphan them. - * Done by making `attempts.user_id` nullable: pre-account attempts stay ownerless and hidden from everyone until `python -m scripts.claim_attempts ` assigns them. Milestone 5 makes the column NOT NULL. + * Done by making `attempts.user_id` nullable: pre-account attempts stay ownerless and hidden from everyone until `python -m scripts.claim_attempts ` assigns them. Milestone 6 makes the column NOT NULL. * record the current user on every new attempt * read attempt history for the current user only @@ -156,22 +158,36 @@ Implement V3 incrementally. Each milestone should leave the project in a working * add tests proving users cannot read or write each other's attempts, at both the API and service level * keep the V2 analytics definitions unchanged apart from the user scope -### Milestone 4 — Content Completion +### Milestone 4 — Bilingual Interface (English / 中文) + +Users choose English or Chinese, and each language shows only its own text; the two are never mixed on one page. + +* a language switch in the nav bar + * logged-in users: the choice is saved in their account (a `users` column) and follows them across devices + * visitors: the choice is kept in the browser; the first visit follows the browser language +* all interface text in both languages, through a small in-house dictionary and `t()` helper; no i18n framework is needed for two languages +* Chinese versions of all boss content for the existing bosses: boss locations, phase names, move names, descriptions, telegraphs, counters and common mistakes +* Chinese move names: the name used by a Chinese Sekiro wiki where one exists (recorded with its page), otherwise a translation (marked as such in the data, never presented as official) +* Chinese descriptions are translations of the English, wiki-sourced text; the Chinese moveset shows one note saying so instead of a marker on every move +* the API returns both languages and the frontend picks one, so existing clients keep working +* the user reviews all Chinese text manually +* analytics definitions do not change with the language; only the displayed text does + +### Milestone 5 — Content Completion and Moveset Review Comes before public deployment so the public release is complete. -* add the remaining **main** bosses (mini-bosses stay out of scope), following the existing data rules: sourced from a wiki, boss-level `source_name` / `source_url`, `telegraph` and `common_mistakes` only when the source states them, reviewed by the user -* add a Chinese name to every move: - * prefer the name used by a Chinese Sekiro wiki (option A), recorded with its source - * where no Chinese wiki name exists, provide a translation (option B), stored and shown as a translation, never presented as an official name - * the user reviews all Chinese names manually -* keep boss data quality over quantity; do not create placeholder moves +* add the remaining main bosses, each in both languages: Gyoubu Masataka Oniwa, Headless Ape, Demon of Hatred, Genichiro (Way of Tomoe), Emma the Gentle Blade, Isshin Ashina, Folding Screen Monkeys, Divine Dragon, Inner Father, Inner Genichiro, Inner Isshin. Mini-bosses stay out of scope. +* for new bosses, compare the Fextralife and Fandom wikis; every conflict between them, in English or Chinese, goes to the user for review +* review the movesets of the existing bosses; the user may remove or edit moves + * the seed sync never deletes a move on its own, because attempts may reference it. Removing a move that has recorded attempts needs an explicit decision about those attempts (for example, keep the move but hide it from new attempts, or reassign the attempts) before it is deleted. +* follow the existing data rules: sourced from a wiki, boss-level `source_name` / `source_url`, `telegraph` and `common_mistakes` only when the source states them, no placeholder moves -### Milestone 5 — Public Deployment +### Milestone 6 — Public Deployment -Moved from V2 Milestone 10. It comes after the account milestones because accounts are what make a public instance safe, and after content completion so the public release is complete. See "Public Deployment" below for the work involved. +Moved from V2 Milestone 10. It comes after the account milestones because accounts are what make a public instance safe, and after the bilingual and content milestones so the public release is complete. See "Public Deployment" below for the work involved. -### Milestone 6 — Practice Recommendations +### Milestone 7 — Practice Recommendations * rule-based and explainable, derived only from the user's attempt records * candidate signals include: @@ -180,16 +196,17 @@ Moved from V2 Milestone 10. It comes after the account milestones because accoun * undefeated bosses the user has practiced recently * every recommendation includes its evidence, for example "6 of your last 10 attempts ended in Phase 2; 4 of those were Floating Passage" * failure counts for a move only count attempts that reached a phase containing that move, so dying earlier cannot make a move look "improved" +* recommendations are shown in both languages * the exact rules are decided with the user before implementation -### Milestone 7 — Integration Testing, CI and Release +### Milestone 8 — Integration Testing, CI and Release * an end-to-end test: register → record attempts → analytics → recommendations * extend CI to cover authentication and user isolation * verify the production deployment workflow * release `v3.0.0` -### Milestone 8 — Move Video References (Deferred) +### Milestone 9 — Move Video References (Deferred) Short clips or GIFs showing each move, so players can identify it more easily. @@ -274,7 +291,7 @@ V2 should preserve that functionality while adding: * recent vs historical comparisons * overall Sekiro-level analytics * stronger integration testing -* a reliable local deployment with Docker Compose (public hosting moved into V3 Milestone 5) +* a reliable local deployment with Docker Compose (public hosting moved into V3 Milestone 6) --- @@ -417,7 +434,7 @@ These belong to V3–V5 or should only be introduced when justified by an actual * Docker Compose, run locally -V2 has no public hosting and no staging or production environments. Public deployment moved into V3 Milestone 5; see "Public Deployment (V3 Milestone 5)". +V2 has no public hosting and no staging or production environments. Public deployment moved into V3 Milestone 6; see "Public Deployment (V3 Milestone 6)". --- @@ -579,7 +596,7 @@ source_name source_url ``` -`name_zh` is the official Simplified Chinese name. Boss names and (from V3 Milestone 4) move names have Chinese versions; the rest of the UI stays in English for now. +`name_zh` is the official Simplified Chinese name. From V3 Milestone 4 the whole interface and all boss content exist in English and Chinese; see Milestone 4. `source_name` and `source_url` record where the boss's phase and move data came from. All moves of a boss share this source. @@ -1503,14 +1520,14 @@ v2.0.0 (run locally with Docker Compose) --- -# Public Deployment (V3 Milestone 5) +# Public Deployment (V3 Milestone 6) V2 ran locally only. Public deployment was moved into V3 because: * V2 has no authentication, so a public instance would let anyone record attempts into the only attempt history * V3 Milestones 1–3 add user accounts and per-user data, which solves that problem directly -Until Milestone 5 is done, a Cloudflare Tunnel to the local instance is acceptable for temporary remote access, such as from a phone away from home. +Until Milestone 6 is done, a Cloudflare Tunnel to the local instance is acceptable for temporary remote access, such as from a phone away from home. The work involved: @@ -1797,11 +1814,11 @@ A broken persistence or analytics change should normally be detected before merg --- -## Milestone 10 — Stable Deployment (Moved to V3 Milestone 5) +## Milestone 10 — Stable Deployment (Moved to V3 Milestone 6) ### Decision -Public deployment moved into V3 as Milestone 5. See "Public Deployment (V3 Milestone 5)" for the reasons and the work involved. +Public deployment moved into V3 as Milestone 6. See "Public Deployment (V3 Milestone 6)" for the reasons and the work involved. ### What V2 Still Includes @@ -1844,7 +1861,7 @@ Recommended sequence: 13. CI Hardening -14. Stable Deployment (moved to V3 Milestone 5, see Milestone 10) +14. Stable Deployment (moved to V3 Milestone 6, see Milestone 10) 15. Release v2.0.0 ``` @@ -1950,7 +1967,7 @@ These are NOT V3 requirements. When working in this repository: 1. Treat V1 and V2 as completed. -2. Treat V3 as the current active development scope, limited to accounts, content completion, public deployment and rule-based practice recommendations. +2. Treat V3 as the current active development scope, limited to accounts, the bilingual interface, content completion, public deployment and rule-based practice recommendations. 3. Preserve the lightweight manual attempt-recording workflow; accounts must not add fields to it. 4. Do not add detailed manual combat telemetry. 5. Continue supporting `Other` and `Not Sure`. @@ -1968,7 +1985,7 @@ When working in this repository: 17. Keep database access and analytics logic outside route handlers. 18. Avoid unnecessary repository/factory/framework abstractions. 19. Do not create large amounts of low-quality boss data. -20. Do not invent Sekiro mechanics when uncertain, and keep game knowledge traceable to sources. Chinese move names come from a Chinese wiki where one exists; otherwise they are marked as translations. +20. Do not invent Sekiro mechanics when uncertain, and keep game knowledge traceable to sources. Chinese move names come from a Chinese wiki where one exists; otherwise they are marked as translations. Chinese descriptions are translations of the sourced English text. 21. Do not commit secrets, production credentials or private database URLs. 22. Do not add OAuth, email infrastructure, roles, social features or practice goals in V3. 23. Do not add video analysis (V4) or Black Myth: Wukong (V5). @@ -1989,7 +2006,8 @@ If the feature does not clearly belong to: Accounts and Authentication User-Owned Attempts Per-User Analytics and Isolation -Content Completion +Bilingual Interface +Content Completion and Moveset Review Public Deployment Practice Recommendations Testing / CI / Release diff --git a/ROADMAP.md b/ROADMAP.md index c077a5a..4de974a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,18 +83,19 @@ Released as `v2.0.0`. **Core question:** **What should I practice next?** -V3 introduces individual player identity, complete boss content, public deployment and practice recommendations. +V3 introduces individual player identity, an English / Chinese interface, complete boss content, public deployment and practice recommendations. Milestones: 1. ✅ **Accounts and authentication:** register, log in, log out, with hashed passwords and session cookies 2. ✅ **User-owned attempts:** every attempt belongs to a user; existing attempts move to the owner's account 3. ✅ **Per-user analytics:** boss analytics, progression and the Sekiro dashboard show only the user's own data -4. **Content completion:** every main boss, and a Chinese name for every move (from a Chinese wiki where one exists, otherwise marked as a translation) -5. **Public deployment:** a stable URL with HTTPS, backups and deployment from `main` -6. **Practice recommendations:** rule-based suggestions derived from the user's own attempts, each showing its evidence -7. **Integration testing, CI and release** -8. **Move video references (deferred):** short clips showing each move; not required for `v3.0.0` +4. **Bilingual interface:** switch between English and Chinese, with each language showing only its own text; saved in the account, or in the browser for visitors +5. **Content completion and moveset review:** every main boss in both languages, and a review of the existing movesets +6. **Public deployment:** a stable URL with HTTPS, backups and deployment from `main` +7. **Practice recommendations:** rule-based suggestions derived from the user's own attempts, each showing its evidence +8. **Integration testing, CI and release** +9. **Move video references (deferred):** short clips showing each move; not required for `v3.0.0` Example output: diff --git a/backend/app/auth.py b/backend/app/auth.py index 51baddf..8a0e624 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -14,7 +14,7 @@ def _cookie_secure() -> bool: # Local development runs over plain HTTP, so Secure is opt-in until the - # HTTPS deployment (V3 Milestone 5) sets COOKIE_SECURE=true. + # HTTPS deployment (V3 Milestone 6) sets COOKIE_SECURE=true. return os.environ.get("COOKIE_SECURE", "false").lower() == "true" From fb8af2626c67205e1b89be0af93610a6ccd65fec Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 23:10:53 -0400 Subject: [PATCH 07/19] Store Chinese move names with their source (V3 Milestone 4, part 1) - moves.name_zh / name_zh_source ('wiki' | 'translation') / name_zh_source_url, with check constraints added by hand to the reviewed migration (autogenerate misses them) - seed validation requires a Chinese name for every move; 102 existing moves named: 6 from BWIKI combat-art pages, 96 translations - API returns the Chinese names; interim frontend display to be replaced by the language switch Co-Authored-By: Claude Opus 5.5 --- .../01e9ec08d5ac_add_move_chinese_names.py | 46 + backend/app/db/models.py | 18 +- backend/app/models/analytics.py | 1 + backend/app/models/boss.py | 17 +- backend/app/seed.py | 14 +- backend/app/services/boss_service.py | 3 + .../app/services/game_analytics_service.py | 5 +- backend/seed/bosses.json | 850 ++++++++++++++---- backend/tests/test_move_chinese_names.py | 77 ++ backend/tests/test_sekiro_analytics.py | 1 + .../src/components/AnalyticsPanel.test.tsx | 4 +- .../src/components/AttemptHistory.test.tsx | 2 +- frontend/src/components/ChineseMoveName.tsx | 31 + .../src/components/MovesetReference.test.tsx | 25 +- frontend/src/components/MovesetReference.tsx | 2 + .../src/components/RecordAttemptForm.test.tsx | 4 +- frontend/src/components/RecordAttemptForm.tsx | 3 +- frontend/src/index.css | 21 + frontend/src/pages/BossDashboardPage.test.tsx | 2 +- .../src/pages/SekiroDashboardPage.test.tsx | 2 + frontend/src/pages/SekiroDashboardPage.tsx | 3 +- frontend/src/types/index.ts | 7 + frontend/src/utils/moves.test.ts | 4 +- frontend/src/utils/moves.ts | 13 +- 24 files changed, 967 insertions(+), 188 deletions(-) create mode 100644 backend/alembic/versions/01e9ec08d5ac_add_move_chinese_names.py create mode 100644 backend/tests/test_move_chinese_names.py create mode 100644 frontend/src/components/ChineseMoveName.tsx diff --git a/backend/alembic/versions/01e9ec08d5ac_add_move_chinese_names.py b/backend/alembic/versions/01e9ec08d5ac_add_move_chinese_names.py new file mode 100644 index 0000000..3f7c39f --- /dev/null +++ b/backend/alembic/versions/01e9ec08d5ac_add_move_chinese_names.py @@ -0,0 +1,46 @@ +"""add move chinese names + +Revision ID: 01e9ec08d5ac +Revises: 30b90438d428 +Create Date: 2026-09-23 22:26:38.514977 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '01e9ec08d5ac' +down_revision: Union[str, Sequence[str], None] = '30b90438d428' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema: Chinese move names, from a Chinese wiki or marked as translations. + + Existing rows get NULLs, which satisfy the checks; the next seed sync fills them in. + """ + op.add_column('moves', sa.Column('name_zh', sa.String(length=200), nullable=True)) + op.add_column('moves', sa.Column('name_zh_source', sa.String(length=20), nullable=True)) + op.add_column('moves', sa.Column('name_zh_source_url', sa.Text(), nullable=True)) + # Autogenerate does not detect check constraints; these mirror models.Move. + op.create_check_constraint( + 'ck_moves_name_zh_source', 'moves', "name_zh_source IS NULL OR name_zh_source IN ('wiki', 'translation')" + ) + op.create_check_constraint('ck_moves_name_zh_has_source', 'moves', '(name_zh IS NULL) = (name_zh_source IS NULL)') + op.create_check_constraint( + 'ck_moves_name_zh_source_url', 'moves', "(name_zh_source = 'wiki') = (name_zh_source_url IS NOT NULL)" + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('ck_moves_name_zh_source_url', 'moves', type_='check') + op.drop_constraint('ck_moves_name_zh_has_source', 'moves', type_='check') + op.drop_constraint('ck_moves_name_zh_source', 'moves', type_='check') + op.drop_column('moves', 'name_zh_source_url') + op.drop_column('moves', 'name_zh_source') + op.drop_column('moves', 'name_zh') diff --git a/backend/app/db/models.py b/backend/app/db/models.py index ab2c757..3e3bb95 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -63,7 +63,18 @@ class BossPhase(Base): class Move(Base): __tablename__ = "moves" - __table_args__ = (UniqueConstraint("boss_id", "slug"),) + __table_args__ = ( + UniqueConstraint("boss_id", "slug"), + CheckConstraint( + "name_zh_source IS NULL OR name_zh_source IN ('wiki', 'translation')", + name="ck_moves_name_zh_source", + ), + CheckConstraint("(name_zh IS NULL) = (name_zh_source IS NULL)", name="ck_moves_name_zh_has_source"), + # A name taken from a Chinese wiki must say which page; a translation has no page. + CheckConstraint( + "(name_zh_source = 'wiki') = (name_zh_source_url IS NOT NULL)", name="ck_moves_name_zh_source_url" + ), + ) id: Mapped[int] = mapped_column(primary_key=True) boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE")) @@ -74,6 +85,11 @@ class Move(Base): telegraph: Mapped[str | None] = mapped_column(Text) counter: Mapped[str | None] = mapped_column(Text) common_mistakes: Mapped[str | None] = mapped_column(Text) + # Chinese name: either the name used by a Chinese wiki ('wiki', with the page in + # name_zh_source_url) or a translation ('translation'), never presented as official. + name_zh: Mapped[str | None] = mapped_column(String(200)) + name_zh_source: Mapped[str | None] = mapped_column(String(20)) + name_zh_source_url: Mapped[str | None] = mapped_column(Text) boss: Mapped[Boss] = relationship(back_populates="moves") phase_links: Mapped[list["PhaseMove"]] = relationship(back_populates="move", cascade="all, delete-orphan") diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py index eecafbb..04ec0ca 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -53,6 +53,7 @@ class RecentAttempt(BaseModel): phase_reached: int failure_move_id: str | None = None failure_move_name: str | None = None + failure_move_name_zh: str | None = None failure_category: FailureCategory | None = None diff --git a/backend/app/models/boss.py b/backend/app/models/boss.py index 617de2d..f956ce8 100644 --- a/backend/app/models/boss.py +++ b/backend/app/models/boss.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Literal + +from pydantic import BaseModel, model_validator class BossMove(BaseModel): @@ -9,6 +11,19 @@ class BossMove(BaseModel): telegraph: str | None = None counter: str | None = None common_mistakes: str | None = None + name_zh: str | None = None + # "wiki": the name used by a Chinese wiki, see name_zh_source_url. + # "translation": translated for this app; not an official name. + name_zh_source: Literal["wiki", "translation"] | None = None + name_zh_source_url: str | None = None + + @model_validator(mode="after") + def check_chinese_name_source(self) -> "BossMove": + if (self.name_zh is None) != (self.name_zh_source is None): + raise ValueError(f"move '{self.id}': name_zh and name_zh_source must be set together") + if (self.name_zh_source == "wiki") != (self.name_zh_source_url is not None): + raise ValueError(f"move '{self.id}': a wiki name needs name_zh_source_url; a translation must not have one") + return self class BossPhase(BaseModel): diff --git a/backend/app/seed.py b/backend/app/seed.py index 3c4165b..fea609b 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -16,7 +16,17 @@ GAMES = {"sekiro": "Sekiro: Shadows Die Twice"} -MOVE_FIELDS = ("name", "move_type", "description", "telegraph", "counter", "common_mistakes") +MOVE_FIELDS = ( + "name", + "move_type", + "description", + "telegraph", + "counter", + "common_mistakes", + "name_zh", + "name_zh_source", + "name_zh_source_url", +) class SeedError(ValueError): @@ -57,6 +67,8 @@ def _validate(bosses: list[BossData]) -> None: if len(move_ids) != len(set(move_ids)): errors.append(f"boss '{boss.id}' phase {phase.phase_number} lists a move twice") for move in phase.moves: + if move.name_zh is None: + errors.append(f"boss '{boss.id}' move '{move.id}' has no Chinese name (name_zh)") data = move.model_dump(include=set(MOVE_FIELDS)) if move.id not in first_seen: first_seen[move.id] = (phase.phase_number, data) diff --git a/backend/app/services/boss_service.py b/backend/app/services/boss_service.py index 7d5fc0d..72da43f 100644 --- a/backend/app/services/boss_service.py +++ b/backend/app/services/boss_service.py @@ -46,6 +46,9 @@ def get_boss(session: Session, boss_slug: str) -> Boss | None: telegraph=link.move.telegraph, counter=link.move.counter, common_mistakes=link.move.common_mistakes, + name_zh=link.move.name_zh, + name_zh_source=link.move.name_zh_source, + name_zh_source_url=link.move.name_zh_source_url, ) for link in phase.move_links ], diff --git a/backend/app/services/game_analytics_service.py b/backend/app/services/game_analytics_service.py index 635fba4..5314b98 100644 --- a/backend/app/services/game_analytics_service.py +++ b/backend/app/services/game_analytics_service.py @@ -88,7 +88,7 @@ def _boss_rows(session: Session, user_id: int) -> list[BossComparisonRow]: def _recent_attempts(session: Session, user_id: int) -> list[RecentAttempt]: rows = session.execute( - select(db.Attempt, db.Boss.slug, db.Boss.name, db.Move.slug, db.Move.name) + select(db.Attempt, db.Boss.slug, db.Boss.name, db.Move.slug, db.Move.name, db.Move.name_zh) .join(db.Boss, db.Attempt.boss_id == db.Boss.id) .outerjoin(db.Move, db.Attempt.failure_move_id == db.Move.id) .where(db.Attempt.user_id == user_id) @@ -105,9 +105,10 @@ def _recent_attempts(session: Session, user_id: int) -> list[RecentAttempt]: phase_reached=attempt.phase_reached, failure_move_id=move_slug, failure_move_name=move_name, + failure_move_name_zh=move_name_zh, failure_category=FailureCategory(attempt.failure_category) if attempt.failure_category else None, ) - for attempt, boss_slug, boss_name, move_slug, move_name in rows + for attempt, boss_slug, boss_name, move_slug, move_name, move_name_zh in rows ] diff --git a/backend/seed/bosses.json b/backend/seed/bosses.json index 064fbc0..e57a300 100644 --- a/backend/seed/bosses.json +++ b/backend/seed/bosses.json @@ -19,7 +19,10 @@ "description": "Delivers an overhead smash while airborne and connects with a Perilous Thrust upon landing.", "telegraph": "Perilous Attack kanji.", "counter": "In this phase he always follows with a Perilous Thrust, opening him up to an easy Mikiri Counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危攻击后续追击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-attack", @@ -28,7 +31,10 @@ "description": "Runs towards Wolf and does a plunging attack.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-grab", @@ -37,7 +43,10 @@ "description": "A sideways jumping slash to the left, then a quick elbow jab; if it connects, he punches Wolf in the stomach.", "telegraph": "Perilous Attack kanji.", "counter": "Striking him during this attack topples his balance, even with a visible block at the end of the move.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·投技", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-thrust", @@ -46,7 +55,10 @@ "description": "Thrusts with his katana.", "telegraph": "Perilous Attack kanji.", "counter": "Mikiri Counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·突刺", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-sweep", @@ -55,7 +67,10 @@ "description": "Sweeps the floor and follows up with an arrow shot.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "advance", @@ -64,7 +79,10 @@ "description": "Two horizontal slashes while stepping forth.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突进", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "chasing-slice", @@ -73,7 +91,10 @@ "description": "Runs a short distance, then slashes twice.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "追击斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "closing-tide", @@ -82,7 +103,10 @@ "description": "Slides and shoots one or two arrows, then rolls forward and slashes once. In Phase 1, it is always his first attack.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "涨潮", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "floating-passage", @@ -91,7 +115,10 @@ "description": "A deadly seven-hit combo ending in a guard-breaking strike. If the target is too far away, he may cancel it at any time into an arrow shot.", "telegraph": "Starts with him sliding to the left.", "counter": "If your posture bar is almost full, deflect the first two hits, then dodge away and punish him once he finishes.", - "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot." + "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot.", + "name_zh": "绝技·飞渡浮舟", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F" }, { "id": "guard-breaking-shot", @@ -100,7 +127,10 @@ "description": "Notches an arrow with incredible force, capable of breaking guard when released.", "telegraph": null, "counter": null, - "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot." + "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot.", + "name_zh": "破防射击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "hawk-shot", @@ -109,7 +139,10 @@ "description": "Stays airborne and shoots rapidly four times.", "telegraph": "Always dashes backward first.", "counter": "Interrupt the move as it starts, or dodge all the arrows by dashing twice toward him.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "鹰射", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "iaijutsu", @@ -118,7 +151,10 @@ "description": "A quick drawn strike with a short closing distance.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "居合斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "receding-tide", @@ -127,7 +163,10 @@ "description": "Slides back and shoots two arrows at the same time, then prepares for an Iaijutsu.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "退潮", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "two-hit-combo", @@ -136,7 +175,10 @@ "description": "Spins his body, slashing, then kicks right after. May follow up with a Guard Breaking Shot.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "二连击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -151,7 +193,10 @@ "description": "Delivers an overhead smash while airborne and connects with a Perilous Thrust or a Perilous Sweep upon landing.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危攻击后续追击(第二阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-attack", @@ -160,7 +205,10 @@ "description": "Runs towards Wolf and does a plunging attack.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-grab", @@ -169,7 +217,10 @@ "description": "A sideways jumping slash to the left, then a quick elbow jab; if it connects, he punches Wolf in the stomach.", "telegraph": "Perilous Attack kanji.", "counter": "Striking him during this attack topples his balance, even with a visible block at the end of the move.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·投技", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-thrust", @@ -178,7 +229,10 @@ "description": "Thrusts with his katana.", "telegraph": "Perilous Attack kanji.", "counter": "Mikiri Counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·突刺", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-sweep", @@ -187,7 +241,10 @@ "description": "Sweeps the floor and follows up with an arrow shot.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "advance", @@ -196,7 +253,10 @@ "description": "Two horizontal slashes while stepping forth.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突进", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "chasing-slice", @@ -205,7 +265,10 @@ "description": "Runs a short distance, then slashes twice.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "追击斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "closing-tide", @@ -214,7 +277,10 @@ "description": "Slides and shoots one or two arrows, then rolls forward and slashes once. In Phase 1, it is always his first attack.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "涨潮", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "floating-passage", @@ -223,7 +289,10 @@ "description": "A deadly seven-hit combo ending in a guard-breaking strike. If the target is too far away, he may cancel it at any time into an arrow shot.", "telegraph": "Starts with him sliding to the left.", "counter": "If your posture bar is almost full, deflect the first two hits, then dodge away and punish him once he finishes.", - "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot." + "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot.", + "name_zh": "绝技·飞渡浮舟", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F" }, { "id": "guard-breaking-shot", @@ -232,7 +301,10 @@ "description": "Notches an arrow with incredible force, capable of breaking guard when released.", "telegraph": null, "counter": null, - "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot." + "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot.", + "name_zh": "破防射击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "hawk-shot", @@ -241,7 +313,10 @@ "description": "Stays airborne and shoots rapidly four times.", "telegraph": "Always dashes backward first.", "counter": "Interrupt the move as it starts, or dodge all the arrows by dashing twice toward him.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "鹰射", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "iaijutsu", @@ -250,7 +325,10 @@ "description": "A quick drawn strike with a short closing distance.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "居合斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "receding-tide", @@ -259,7 +337,10 @@ "description": "Slides back and shoots two arrows at the same time, then prepares for an Iaijutsu.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "退潮", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "two-hit-combo", @@ -268,7 +349,10 @@ "description": "Spins his body, slashing, then kicks right after. May follow up with a Guard Breaking Shot.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "二连击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -283,7 +367,10 @@ "description": "Lunges forward, thrusting over a long distance. He always opens Phase 3 with this move. May follow up with a Perilous Grab.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": "Using an item - he answers it with this thrust." + "common_mistakes": "Using an item - he answers it with this thrust.", + "name_zh": "死亡突刺", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "floating-passage-phase-3", @@ -292,7 +379,10 @@ "description": "A deadly seven-hit combo ending in a guard-breaking strike, followed by a delayed Perilous Thrust. If the target is too far away, he may cancel it at any time into an arrow shot.", "telegraph": "Perilous Attack kanji. Starts with him sliding to the left.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "绝技·飞渡浮舟(第三阶段)", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F" }, { "id": "perilous-attack-follow-up-phase-3", @@ -301,7 +391,10 @@ "description": "Delivers an overhead smash while airborne, then one of three follow-ups: a Perilous Thrust, a Perilous Sweep, or a quick upward cut that, if deflected, is followed by a delayed Perilous Thrust.", "telegraph": "Perilous Attack kanji. After landing: crouching down means a Perilous Thrust, turning clockwise means a Perilous Sweep, turning anti-clockwise means the quick upward cut.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危攻击后续追击(第三阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-grab-phase-3", @@ -310,7 +403,10 @@ "description": "Runs toward Wolf attempting to grab him. If it connects, he throws Wolf to the ground and stabs him in the chest.", "telegraph": "Perilous Attack kanji.", "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·投技(第三阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "lightning-of-tomoe-shot", @@ -319,7 +415,10 @@ "description": "Shoots an electrifying arrow that instantly inflicts Shock.", "telegraph": "Jumps up and imbues his arrow with lightning.", "counter": "Lightning Reversal sends it back for significant posture and vitality damage and briefly paralyzes him. His lightning attacks can also be interrupted by hitting him, or avoided by dashing under him.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "巴之雷·射击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "lightning-of-tomoe-slash", @@ -328,7 +427,10 @@ "description": "Leaps up, then slashes forward in a long horizontal arc of lightning.", "telegraph": "Raises his katana to his right with both hands as it is imbued with lightning.", "counter": "Lightning Reversal sends it back for significant posture and vitality damage and briefly paralyzes him. His lightning attacks can also be interrupted by hitting him, or avoided by dashing under him.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "巴之雷·斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "lightning-of-tomoe-smash", @@ -337,7 +439,10 @@ "description": "Leaps up, then smashes straight down with lightning.", "telegraph": "Raises his katana to his left as it is imbued with lightning.", "counter": "Lightning Reversal sends it back for significant posture and vitality damage and briefly paralyzes him. His lightning attacks can also be interrupted by hitting him, or avoided by dashing under him.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "巴之雷·砸击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -363,7 +468,10 @@ "description": "Many of Owl's attacks begin with a zig-zag motion designed to confuse the player, leading into one of five possible follow-up attacks.", "telegraph": null, "counter": "Stay ready to react to whichever attack follows.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "之字形移动", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shuriken-throw", @@ -372,7 +480,10 @@ "description": "A single shuriken projectile attack.", "telegraph": null, "counter": "Block, deflect, or dodge to the side.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷手里剑", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "double-shuriken-flip", @@ -381,7 +492,10 @@ "description": "Two shuriken throws followed by an aerial flip and downward sword strike.", "telegraph": null, "counter": "Deflect/block the shurikens and dodge sideways to avoid the flip, then counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "双手里剑 + 后翻", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shadowfall", @@ -390,7 +504,10 @@ "description": "Unblockable thrust; if it hits, he jumps off and comes down with two huge slashes.", "telegraph": "Charges up before the unblockable thrust.", "counter": "Mikiri the thrust.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "秘传·巨型忍者落杀", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E5%B7%A8%E5%9E%8B%E5%BF%8D%E8%80%85%E8%90%BD%E6%9D%80" }, { "id": "shuriken-slash", @@ -399,7 +516,10 @@ "description": "Throws a shuriken immediately followed by a running slash.", "telegraph": null, "counter": "Block/deflect both. A well-timed dodge to the right is also possible.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-sweep", @@ -408,7 +528,10 @@ "description": "Unblockable sweep attack performed from the side.", "telegraph": null, "counter": "Jump over it and stay close to bait his overhead slash, or jump back for a jump kick to the head.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "large-overhead-slash", @@ -417,7 +540,10 @@ "description": "A very powerful overhead slash that adapts based on the player's response.", "telegraph": "Loads up for the overhead slash.", "counter": "Dodge at the last moment and counter, or step back out of range. Hits can land during the charge-up.", - "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack." + "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack.", + "name_zh": "大幅度下劈", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "backflip", @@ -426,7 +552,10 @@ "description": "Leaps away; deals no vitality damage but significant posture damage.", "telegraph": null, "counter": "Deflect to minimize posture damage.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "firecrackers", @@ -435,7 +564,10 @@ "description": "Throws blinding firecrackers followed by an explosion; can chain into Shuriken Throw, Shadowfall, or Shuriken Slash.", "telegraph": null, "counter": "Dodge backwards to avoid.", - "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you." + "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you.", + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "running-slash", @@ -444,7 +576,10 @@ "description": "A single strong slash performed while approaching the player.", "telegraph": null, "counter": "Block/deflect. Dodging to the left is also possible.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "five-slash-firecracker-combo", @@ -453,7 +588,10 @@ "description": "Three consecutive sword slashes, followed by an elbow strike, followed by firecrackers that he slashes through.", "telegraph": null, "counter": "Deflect/block the slashes, then dodge right when the firecrackers are thrown for a counter opportunity.", - "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival." + "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival.", + "name_zh": "五连斩接爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "double-slash-perilous-sweep", @@ -462,7 +600,10 @@ "description": "Two consecutive slashes ending in an unblockable sweep; can chain into Large Overhead Slash.", "telegraph": null, "counter": "Jump over it and stay close to bait his overhead slash.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "二连斩接危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "stomp-deathblow", @@ -471,7 +612,10 @@ "description": "Raises a foot then stomps down, pinning the player and stabbing through the chest for massive damage. Cannot be blocked and gives no Perilous indicator.", "telegraph": "Raises his foot into the air before stomping down. There is no Perilous indicator.", "counter": "Dodge backwards or safety roll to avoid it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "踩踏(忍杀)", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -486,7 +630,10 @@ "description": "Many of Owl's attacks begin with a zig-zag motion designed to confuse the player, leading into one of five possible follow-up attacks.", "telegraph": null, "counter": "Stay ready to react to whichever attack follows.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "之字形移动", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shuriken-throw", @@ -495,7 +642,10 @@ "description": "A single shuriken projectile attack.", "telegraph": null, "counter": "Block, deflect, or dodge to the side.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷手里剑", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "double-shuriken-flip", @@ -504,7 +654,10 @@ "description": "Two shuriken throws followed by an aerial flip and downward sword strike.", "telegraph": null, "counter": "Deflect/block the shurikens and dodge sideways to avoid the flip, then counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "双手里剑 + 后翻", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shadowfall", @@ -513,7 +666,10 @@ "description": "Unblockable thrust; if it hits, he jumps off and comes down with two huge slashes.", "telegraph": "Charges up before the unblockable thrust.", "counter": "Mikiri the thrust.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "秘传·巨型忍者落杀", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E5%B7%A8%E5%9E%8B%E5%BF%8D%E8%80%85%E8%90%BD%E6%9D%80" }, { "id": "shuriken-slash", @@ -522,7 +678,10 @@ "description": "Throws a shuriken immediately followed by a running slash.", "telegraph": null, "counter": "Block/deflect both. A well-timed dodge to the right is also possible.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-sweep", @@ -531,7 +690,10 @@ "description": "Unblockable sweep attack performed from the side.", "telegraph": null, "counter": "Jump over it and stay close to bait his overhead slash, or jump back for a jump kick to the head.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "large-overhead-slash", @@ -540,7 +702,10 @@ "description": "A very powerful overhead slash that adapts based on the player's response.", "telegraph": "Loads up for the overhead slash.", "counter": "Dodge at the last moment and counter, or step back out of range. Hits can land during the charge-up.", - "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack." + "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack.", + "name_zh": "大幅度下劈", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "backflip", @@ -549,7 +714,10 @@ "description": "Leaps away; deals no vitality damage but significant posture damage.", "telegraph": null, "counter": "Deflect to minimize posture damage.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "firecrackers", @@ -558,7 +726,10 @@ "description": "Throws blinding firecrackers followed by an explosion; can chain into Shuriken Throw, Shadowfall, or Shuriken Slash.", "telegraph": null, "counter": "Dodge backwards to avoid.", - "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you." + "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you.", + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "running-slash", @@ -567,7 +738,10 @@ "description": "A single strong slash performed while approaching the player.", "telegraph": null, "counter": "Block/deflect. Dodging to the left is also possible.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "five-slash-firecracker-combo", @@ -576,7 +750,10 @@ "description": "Three consecutive sword slashes, followed by an elbow strike, followed by firecrackers that he slashes through.", "telegraph": null, "counter": "Deflect/block the slashes, then dodge right when the firecrackers are thrown for a counter opportunity.", - "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival." + "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival.", + "name_zh": "五连斩接爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "double-slash-perilous-sweep", @@ -585,7 +762,10 @@ "description": "Two consecutive slashes ending in an unblockable sweep; can chain into Large Overhead Slash.", "telegraph": null, "counter": "Jump over it and stay close to bait his overhead slash.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "二连斩接危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "stomp-deathblow", @@ -594,7 +774,10 @@ "description": "Raises a foot then stomps down, pinning the player and stabbing through the chest for massive damage. Cannot be blocked and gives no Perilous indicator.", "telegraph": "Raises his foot into the air before stomping down. There is no Perilous indicator.", "counter": "Dodge backwards or safety roll to avoid it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "踩踏(忍杀)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "backflip-shuriken", @@ -603,7 +786,10 @@ "description": "Same as the Phase 1 backflip, with a shuriken thrown afterward.", "telegraph": null, "counter": "Block/deflect both.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后空翻 + 手里剑", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "owl-teleport", @@ -612,7 +798,10 @@ "description": "Uses his summoned owl to teleport above the player and drop down with a slash.", "telegraph": null, "counter": "Deflect the slash, or run/stay away from the owl to avoid it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "枭之瞬移", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "fire-owl-shadowfall", @@ -621,7 +810,10 @@ "description": "Summons his owl and throws an unblockable fire owl projectile, then follows up with Shadowfall.", "telegraph": null, "counter": "Jump over the fire owl and Mikiri the thrust.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "火枭 + 巨型忍者落杀", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -647,7 +839,10 @@ "description": "Projectile attack using kunai.", "telegraph": null, "counter": "Can be blocked or deflected.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷苦无", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-into-rafters", @@ -656,7 +851,10 @@ "description": "Leaps into the air and disappears.", "telegraph": null, "counter": "Use the Shuriken Prosthetic immediately as she begins jumping to knock her out of the air and create an opening.", - "common_mistakes": "Throwing the shuriken too late - she will likely deflect it." + "common_mistakes": "Throwing the shuriken too late - she will likely deflect it.", + "name_zh": "跃上房梁", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-aerial-grab", @@ -665,7 +863,10 @@ "description": "Overhead perilous attack performed while airborne.", "telegraph": "Perilous Attack kanji.", "counter": "Dodge or move out of the way. Alternatively, use the Shuriken Prosthetic to knock her down for a punish window.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·空中投技", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-sweep", @@ -674,7 +875,10 @@ "description": "Low-sweeping perilous attack.", "telegraph": "Perilous Attack kanji.", "counter": "Jump and kick her for considerable posture damage.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "ground-combos", @@ -683,7 +887,10 @@ "description": "Standard melee attack sequences performed from ground level.", "telegraph": null, "counter": "Stay close, deflect attacks, then counter-attack during her recovery windows.", - "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks." + "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks.", + "name_zh": "地面连击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -698,7 +905,10 @@ "description": "Projectile attack using kunai.", "telegraph": null, "counter": "Can be blocked or deflected.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷苦无", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-into-rafters", @@ -707,7 +917,10 @@ "description": "Leaps into the air and disappears.", "telegraph": null, "counter": "Use the Shuriken Prosthetic immediately as she begins jumping to knock her out of the air and create an opening.", - "common_mistakes": "Throwing the shuriken too late - she will likely deflect it." + "common_mistakes": "Throwing the shuriken too late - she will likely deflect it.", + "name_zh": "跃上房梁", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-aerial-grab", @@ -716,7 +929,10 @@ "description": "Overhead perilous attack performed while airborne.", "telegraph": "Perilous Attack kanji.", "counter": "Dodge or move out of the way. Alternatively, use the Shuriken Prosthetic to knock her down for a punish window.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·空中投技", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "perilous-sweep", @@ -725,7 +941,10 @@ "description": "Low-sweeping perilous attack.", "telegraph": "Perilous Attack kanji.", "counter": "Jump and kick her for considerable posture damage.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "ground-combos", @@ -734,7 +953,10 @@ "description": "Standard melee attack sequences performed from ground level.", "telegraph": null, "counter": "Stay close, deflect attacks, then counter-attack during her recovery windows.", - "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks." + "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks.", + "name_zh": "地面连击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "illusions", @@ -743,7 +965,10 @@ "description": "Summons phantom enemies that attack with spears.", "telegraph": null, "counter": "Use a Snap Seed to dispel the phantoms, or kill them individually for Spirit Emblems. Alternatively, run around the arena perimeter to avoid their attacks.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "幻影", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "phantom-kunai", @@ -752,7 +977,10 @@ "description": "Upgraded kunai projectile attack with additional volleys.", "telegraph": null, "counter": "Deflect or block; dodging sideways offers the best protection.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "幻影苦无", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "homing-butterflies", @@ -761,7 +989,10 @@ "description": "Homing projectiles created when illusions are dispelled.", "telegraph": null, "counter": "Hide behind pillars or keep sprinting around the arena perimeter.", - "common_mistakes": "Turning corners while the butterflies are tracking you can make them hit you." + "common_mistakes": "Turning corners while the butterflies are tracking you can make them hit you.", + "name_zh": "追踪幻蝶", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "aerial-attacks-enhanced", @@ -770,7 +1001,10 @@ "description": "Enhanced aerial maneuvers performed with increased frequency.", "telegraph": null, "counter": "Continue using the Shuriken Prosthetic to interrupt; maintain aggressive pressure to prevent aerial transitions.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "空中攻击(强化)", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -796,7 +1030,10 @@ "description": "Rapid slamming combinations performed at close range.", "telegraph": null, "counter": "All slamming attacks can be blocked; attempt to deflect them.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "连续拍地", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "hand-swipe-grab", @@ -805,7 +1042,10 @@ "description": "Unblockable grab attack where the ape swipes the floor from left to right at close range.", "telegraph": null, "counter": "Time a jump to dodge it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "挥手抓取", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "body-slam-grab", @@ -814,7 +1054,10 @@ "description": "The ape jumps and attempts to grab the player.", "telegraph": null, "counter": "Time a dodge to avoid it; a double dodge (tap the button twice) is safer.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "扑身抓取", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "poisonous-fart", @@ -823,7 +1066,10 @@ "description": "Status attack inflicting heavy poison buildup when the ape's back is turned; always followed by running away and jumping high.", "telegraph": null, "counter": "Quickly dodge away from the cloud to avoid the poison.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "毒屁", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "poisonous-poo-throw", @@ -832,7 +1078,10 @@ "description": "High-damage instant poison attack.", "telegraph": "Jumps high before throwing.", "counter": "Run underneath as it jumps and retaliate; its recovery from this attack is lengthy.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷毒粪", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "roar-jump-back", @@ -841,7 +1090,10 @@ "description": "The ape jumps back and unleashes a roar.", "telegraph": null, "counter": "A grapple prompt appears, letting Sekiro close the gap and causing a brief stagger.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "咆哮 / 后跳", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "rolling-body-slam", @@ -850,7 +1102,10 @@ "description": "The ape jumps onto its back and thrashes the ground.", "telegraph": null, "counter": "Stay away from its arms and hit its head.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "翻滚压击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -865,7 +1120,10 @@ "description": "Sword attacks that cover a wide area, after the ape decapitates itself and resurrects wielding a sword through its neck.", "telegraph": null, "counter": "Strike a few times after its combos, then back away.", - "common_mistakes": "Staying on top of it and attacking for too long." + "common_mistakes": "Staying on top of it and attacking for too long.", + "name_zh": "大范围挥刀", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "diving-lunge", @@ -874,7 +1132,10 @@ "description": "Lunges forward with a swipe while falling to the ground.", "telegraph": "Clear wind-up before it dives.", "counter": "Jump over it as it dives and strike it in the air, following with a full combo.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "飞扑", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "overhead-slam", @@ -883,7 +1144,10 @@ "description": "Large overhead attack that can be deflected.", "telegraph": null, "counter": "Deflecting it stuns the boss and exposes its neck. Using the Loaded Spear (activate it twice) briefly pulls out the centipede for massive posture and health damage.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "高举砸地", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "blood-scream", @@ -892,7 +1156,10 @@ "description": "Battle roar that unleashes shockwaves filling the Terror meter, which can inflict Terror status and instantly kill Sekiro.", "telegraph": "Puts its head back on its neck before screaming.", "counter": "Any Loaded Umbrella-type prosthetic completely negates the scream; the Phoenix's Lilac Umbrella is recommended for NG+.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "血之咆哮", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -918,7 +1185,10 @@ "description": "Perilous sweep attack variant, often followed by attack opportunities.", "telegraph": null, "counter": "Jump over it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "横扫攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "thrust-attack", @@ -927,7 +1197,10 @@ "description": "Perilous thrust attack variant.", "telegraph": null, "counter": "Mikiri Counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突刺攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-attack-sweep", @@ -936,7 +1209,10 @@ "description": "Jumping attack that sweeps during the jump.", "telegraph": null, "counter": "Sidestep and strike as she lands.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃攻击(横扫)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-attack-overhead", @@ -945,7 +1221,10 @@ "description": "Jumping attack that ends in an overhead slash.", "telegraph": null, "counter": "Run below and attack her exposed back.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃攻击(下劈)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "upwards-slash", @@ -954,7 +1233,10 @@ "description": "An upward slash that goes through Sekiro's normal defenses and cannot be blocked.", "telegraph": null, "counter": "Can be deflected, though the timing is very precise.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "whirlwind-attack", @@ -963,7 +1245,10 @@ "description": "Continuous spinning attack with the Naginata that can quickly exhaust Sekiro's posture gauge. Tends to be used when at a distance from the player, more often if the player uses a consumable.", "telegraph": null, "counter": "Run away when she starts spinning.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "旋风攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "mid-range-combo", @@ -972,7 +1257,10 @@ "description": "One jumping air hit followed by two ground hits at mid-range.", "telegraph": null, "counter": "Let the landing cancel the air-attack lag, then jump away to mid-range.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "中距离连击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -998,7 +1286,10 @@ "description": "Perilous sweep attack variant, used more often here than in the Mibu Village fight. More telegraphed and predictable in Phase 3.", "telegraph": null, "counter": "Jump over it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "横扫攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "thrust-attack", @@ -1007,7 +1298,10 @@ "description": "Perilous thrust attack variant, used more often here than in the Mibu Village fight.", "telegraph": null, "counter": "Mikiri Counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突刺攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-attack-sweep", @@ -1016,7 +1310,10 @@ "description": "Jumping attack that sweeps during the jump.", "telegraph": null, "counter": "Sidestep and strike as she lands.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃攻击(横扫)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-attack-overhead", @@ -1025,7 +1322,10 @@ "description": "Jumping attack that ends in an overhead slash. In Phase 3 it tends to be used as a finishing move.", "telegraph": null, "counter": "Run below and attack her exposed back.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃攻击(下劈)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "upwards-slash", @@ -1034,7 +1334,10 @@ "description": "An upward slash that goes through Sekiro's normal defenses and cannot be blocked.", "telegraph": null, "counter": "Can be deflected, though the timing is very precise.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "mid-range-combo", @@ -1043,7 +1346,10 @@ "description": "One jumping air hit followed by two ground hits at mid-range.", "telegraph": null, "counter": "Let the landing cancel the air-attack lag, then jump away to mid-range.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "中距离连击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -1058,7 +1364,10 @@ "description": "She disappears and the area fills with mist, then summons three shadow illusions of herself that each attack the player three times. The shadows disappear between attacks, making lock-on difficult. Standing near a wall before the shadows spawn limits them to one direction, repeating the same three moves.", "telegraph": null, "counter": "The shadows don't need to be defeated - she eventually ends the illusion and returns on her own. A Bite Down pill instantly dispels them too.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "召唤幻影", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -1073,7 +1382,10 @@ "description": "Perilous sweep attack variant, used more often here than in the Mibu Village fight. More telegraphed and predictable in Phase 3.", "telegraph": null, "counter": "Jump over it.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "横扫攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "thrust-attack", @@ -1082,7 +1394,10 @@ "description": "Perilous thrust attack variant, used more often here than in the Mibu Village fight.", "telegraph": null, "counter": "Mikiri Counter.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突刺攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-attack-sweep", @@ -1091,7 +1406,10 @@ "description": "Jumping attack that sweeps during the jump.", "telegraph": null, "counter": "Sidestep and strike as she lands.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃攻击(横扫)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "jump-attack-overhead", @@ -1100,7 +1418,10 @@ "description": "Jumping attack that ends in an overhead slash. In Phase 3 it tends to be used as a finishing move.", "telegraph": null, "counter": "Run below and attack her exposed back.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃攻击(下劈)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "upwards-slash", @@ -1109,7 +1430,10 @@ "description": "An upward slash that goes through Sekiro's normal defenses and cannot be blocked.", "telegraph": null, "counter": "Can be deflected, though the timing is very precise.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "mid-range-combo", @@ -1118,7 +1442,10 @@ "description": "One jumping air hit followed by two ground hits at mid-range.", "telegraph": null, "counter": "Let the landing cancel the air-attack lag, then jump away to mid-range.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "中距离连击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "whirlwind-attack", @@ -1127,7 +1454,10 @@ "description": "Continuous spinning attack with the Naginata, regained in this phase after being absent since Phase 1. Very susceptible to the Shinobi Firecracker, especially during the windup.", "telegraph": null, "counter": "Run away when she starts spinning, or interrupt the windup with a Shinobi Firecracker.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "旋风攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "centipede-toxic-goo", @@ -1136,7 +1466,10 @@ "description": "Perilous attack where the demon centipede erupting from her neck spews toxic goo in front of her; can inflict Terror.", "telegraph": null, "counter": "Sidestep or jump-kick when she uses this and retaliate - the attack has lengthy ending lag.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "百足毒液", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "centipede-toxic-cloud", @@ -1145,7 +1478,10 @@ "description": "A non-perilous variant of the centipede's toxin attack.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "百足毒雾", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -1171,7 +1507,10 @@ "description": "Tosses a charm that releases a green mist, preventing healing item usage for 30 seconds.", "telegraph": null, "counter": "Sidestep and then strike, taking care not to get debuffed.", - "common_mistakes": "Walking into the mist, which locks you out of healing items." + "common_mistakes": "Walking into the mist, which locks you out of healing items.", + "name_zh": "忍符", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "backflip", @@ -1180,7 +1519,10 @@ "description": "Leaps backward while throwing a shuriken.", "telegraph": null, "counter": "Deflect or dodge the projectile.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "running-slash", @@ -1189,7 +1531,10 @@ "description": "A single powerful slash performed while charging toward the player.", "telegraph": null, "counter": "Deflect or dodge depending on posture status.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "four-slash-combo", @@ -1198,7 +1543,10 @@ "description": "Four consecutive sword strikes, often followed by Shinobi Charm.", "telegraph": null, "counter": "Deflect or retreat.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "四连斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shuriken-throw-flip", @@ -1207,7 +1555,10 @@ "description": "Two shuriken throws followed by an airborne downward strike.", "telegraph": null, "counter": "Dodge diagonally forward through the flip strike to open up an opportunity to attack 3-4 times, or land two Mortal Draw hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷手里剑 + 翻身", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shuriken-slash", @@ -1216,7 +1567,10 @@ "description": "A quick shuriken throw immediately followed by a running slash.", "telegraph": null, "counter": "Sidestep the projectile, then deflect or retreat.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "firecracker", @@ -1225,7 +1579,10 @@ "description": "Black gunpowder explosion attack dealing significant damage.", "telegraph": "Black smoke.", "counter": "Run away when you see black smoke, or dodge in aggressively.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "stomp-grab", @@ -1234,7 +1591,10 @@ "description": "Raises a foot then stomps down, pinning and stabbing the player. Deals massive damage, cannot be blocked, and gives no Perilous indicator.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "踩踏(投技)", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -1249,7 +1609,10 @@ "description": "Tosses a charm that releases a green mist, preventing healing item usage for 30 seconds.", "telegraph": null, "counter": "Sidestep and then strike, taking care not to get debuffed.", - "common_mistakes": "Walking into the mist, which locks you out of healing items." + "common_mistakes": "Walking into the mist, which locks you out of healing items.", + "name_zh": "忍符", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "backflip-poison", @@ -1258,7 +1621,10 @@ "description": "Same motion as the Phase 1 backflip, but throws poison instead of a shuriken.", "telegraph": null, "counter": "Dodge and maintain distance from the poisoned area.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后空翻(毒)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "running-slash", @@ -1267,7 +1633,10 @@ "description": "A single powerful slash performed while charging toward the player.", "telegraph": null, "counter": "Deflect or dodge depending on posture status.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "four-slash-combo", @@ -1276,7 +1645,10 @@ "description": "Four consecutive sword strikes, often followed by Shinobi Charm.", "telegraph": null, "counter": "Deflect or retreat.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "四连斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shuriken-throw-flip", @@ -1285,7 +1657,10 @@ "description": "Two shuriken throws followed by an airborne downward strike.", "telegraph": null, "counter": "Dodge diagonally forward through the flip strike to open up an opportunity to attack 3-4 times, or land two Mortal Draw hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "投掷手里剑 + 翻身", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "shuriken-slash", @@ -1294,7 +1669,10 @@ "description": "A quick shuriken throw immediately followed by a running slash.", "telegraph": null, "counter": "Sidestep the projectile, then deflect or retreat.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "firecracker", @@ -1303,7 +1681,10 @@ "description": "Black gunpowder explosion attack dealing significant damage.", "telegraph": "Black smoke.", "counter": "Run away when you see black smoke, or dodge in aggressively.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "stomp-grab", @@ -1312,7 +1693,10 @@ "description": "Raises a foot then stomps down, pinning and stabbing the player. Deals massive damage, cannot be blocked, and gives no Perilous indicator.", "telegraph": null, "counter": null, - "common_mistakes": null + "common_mistakes": null, + "name_zh": "踩踏(投技)", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } @@ -1338,7 +1722,10 @@ "description": "Strikes downward launching a force wave, followed by a second wave after a short delay.", "telegraph": "Sheathes his sword and readies a stance; a glint shows on the hilt.", "counter": "Deflect the first wave, then dodge the second.", - "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage." + "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", + "name_zh": "秘传·龙闪", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA" }, { "id": "ashina-cross", @@ -1347,7 +1734,10 @@ "description": "Steps forward with two rapid slashes.", "telegraph": "Sheathes his sword; after a short delay there is a flash of light at his hand as he steps forward.", "counter": "Press deflect twice without pausing, or run far away to avoid it entirely.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "绝技·苇名十字斩", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9" }, { "id": "ashina-cross-cancel", @@ -1356,7 +1746,10 @@ "description": "If the player is close when he readies Ashina Cross, he cancels it into a quick sword smack followed by a perilous sweep. He also does this after the player repeatedly deflects his attacks.", "telegraph": null, "counter": "Deflect the initial hit, then jump and stomp.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "苇名十字斩(取消)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "ichimonji-double", @@ -1365,7 +1758,10 @@ "description": "Performs a double overhead strike.", "telegraph": "Raises his blade into a ready stance.", "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "一字斩·二连", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E" }, { "id": "distance-slash", @@ -1374,7 +1770,10 @@ "description": "If the player is far away or using an item, he quickly dashes in and slashes.", "telegraph": null, "counter": "Maintain enough distance to react in time.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "charged-horizontal-force-wave", @@ -1383,7 +1782,10 @@ "description": "Swings in a spinning motion, causing a large circular force wave.", "telegraph": "Sheathes his sword and charges the attack.", "counter": "Wait, then deflect as soon as he begins moving his arm, or sprint and jump toward him, then jump again onto his head.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "蓄力横向剑气", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "three-to-four-hit-combo", @@ -1392,7 +1794,10 @@ "description": "A series of main slashes with delays between hits.", "telegraph": null, "counter": "Deflect each hit; once he finishes slashing he's open for a quick two hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "三至四连击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "thrust", @@ -1401,7 +1806,10 @@ "description": "A telegraphed thrust attack.", "telegraph": null, "counter": "Mikiri Counter - easy to react to once telegraphed.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突刺", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "long-range-wind-attack", @@ -1410,7 +1818,10 @@ "description": "A long-range wind-based attack.", "telegraph": null, "counter": "Dodge by running sideways to gain a couple of free hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离风压攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "close-range-full-screen-sweep", @@ -1419,7 +1830,10 @@ "description": "A close-range sweep that covers the full screen.", "telegraph": null, "counter": "Sprint backwards before it goes off.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "近距离全屏横扫", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -1434,7 +1848,10 @@ "description": "Strikes downward launching a force wave, followed by a second wave after a short delay.", "telegraph": "Sheathes his sword and readies a stance; a glint shows on the hilt.", "counter": "Deflect the first wave, then dodge the second.", - "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage." + "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", + "name_zh": "秘传·龙闪", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA" }, { "id": "ashina-cross", @@ -1443,7 +1860,10 @@ "description": "Steps forward with two rapid slashes.", "telegraph": "Sheathes his sword; after a short delay there is a flash of light at his hand as he steps forward.", "counter": "Press deflect twice without pausing, or run far away to avoid it entirely.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "绝技·苇名十字斩", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9" }, { "id": "ashina-cross-cancel", @@ -1452,7 +1872,10 @@ "description": "If the player is close when he readies Ashina Cross, he cancels it into a quick sword smack followed by a perilous sweep. He also does this after the player repeatedly deflects his attacks.", "telegraph": null, "counter": "Deflect the initial hit, then jump and stomp.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "苇名十字斩(取消)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "ichimonji-double", @@ -1461,7 +1884,10 @@ "description": "Performs a double overhead strike.", "telegraph": "Raises his blade into a ready stance.", "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "一字斩·二连", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E" }, { "id": "distance-slash", @@ -1470,7 +1896,10 @@ "description": "If the player is far away or using an item, he quickly dashes in and slashes.", "telegraph": null, "counter": "Maintain enough distance to react in time.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "charged-horizontal-force-wave", @@ -1479,7 +1908,10 @@ "description": "Swings in a spinning motion, causing a large circular force wave.", "telegraph": "Sheathes his sword and charges the attack.", "counter": "Wait, then deflect as soon as he begins moving his arm, or sprint and jump toward him, then jump again onto his head.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "蓄力横向剑气", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "three-to-four-hit-combo", @@ -1488,7 +1920,10 @@ "description": "A series of main slashes with delays between hits.", "telegraph": null, "counter": "Deflect each hit; once he finishes slashing he's open for a quick two hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "三至四连击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "thrust", @@ -1497,7 +1932,10 @@ "description": "A telegraphed thrust attack.", "telegraph": null, "counter": "Mikiri Counter - easy to react to once telegraphed.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突刺", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "long-range-wind-attack", @@ -1506,7 +1944,10 @@ "description": "A long-range wind-based attack.", "telegraph": null, "counter": "Dodge by running sideways to gain a couple of free hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离风压攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "close-range-full-screen-sweep", @@ -1515,7 +1956,10 @@ "description": "A close-range sweep that covers the full screen.", "telegraph": null, "counter": "Sprint backwards before it goes off.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "近距离全屏横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "charged-double-projected-force", @@ -1524,7 +1968,10 @@ "description": "Makes two slashes that each launch a force disc. Deflecting the discs does not affect his posture.", "telegraph": "Sheathes his sword and charges the attack.", "counter": "The discs deal damage through block if not deflected properly, but the attack is easy to dodge.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "蓄力双重剑气", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "leaping-slam", @@ -1533,7 +1980,10 @@ "description": "When far away or the player is using an item, he leaps and slams with his spear, followed by a sword strike or gunshots.", "telegraph": "Leaps into the air before slamming down with his spear.", "counter": "Deflect the slam, then counter-attack.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃砸击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "retreating-sweep", @@ -1542,7 +1992,10 @@ "description": "Leaps backward into the air and sweeps with his spear, often following several blocked player attacks. Comes out very quickly.", "telegraph": "Leaps backward into the air.", "counter": "Deflect, or quickly dodge forward for a free hit.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后撤横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "quad-shot", @@ -1551,7 +2004,10 @@ "description": "At a distance, fires several rapid pistol shots followed by a large thrust from the spear. The last bullet is slightly delayed.", "telegraph": "Pulls out a pistol.", "counter": "Learn to deflect the shots, then Mikiri counter the following thrust or jump-attack if it's a sweep. Simply blocking the bullets damages posture heavily.", - "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture." + "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture.", + "name_zh": "四连射击", + "name_zh_source": "translation", + "name_zh_source_url": null } ] }, @@ -1566,7 +2022,10 @@ "description": "Strikes downward launching a force wave, followed by a second wave after a short delay.", "telegraph": "Sheathes his sword and readies a stance; a glint shows on the hilt.", "counter": "Deflect the first wave, then dodge the second.", - "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage." + "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", + "name_zh": "秘传·龙闪", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA" }, { "id": "ashina-cross", @@ -1575,7 +2034,10 @@ "description": "Steps forward with two rapid slashes.", "telegraph": "Sheathes his sword; after a short delay there is a flash of light at his hand as he steps forward.", "counter": "Press deflect twice without pausing, or run far away to avoid it entirely.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "绝技·苇名十字斩", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9" }, { "id": "ashina-cross-cancel", @@ -1584,7 +2046,10 @@ "description": "If the player is close when he readies Ashina Cross, he cancels it into a quick sword smack followed by a perilous sweep. He also does this after the player repeatedly deflects his attacks.", "telegraph": null, "counter": "Deflect the initial hit, then jump and stomp.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "苇名十字斩(取消)", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "ichimonji-double", @@ -1593,7 +2058,10 @@ "description": "Performs a double overhead strike.", "telegraph": "Raises his blade into a ready stance.", "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "一字斩·二连", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E" }, { "id": "distance-slash", @@ -1602,7 +2070,10 @@ "description": "If the player is far away or using an item, he quickly dashes in and slashes.", "telegraph": null, "counter": "Maintain enough distance to react in time.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离斩击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "charged-horizontal-force-wave", @@ -1611,7 +2082,10 @@ "description": "Swings in a spinning motion, causing a large circular force wave.", "telegraph": "Sheathes his sword and charges the attack.", "counter": "Wait, then deflect as soon as he begins moving his arm, or sprint and jump toward him, then jump again onto his head.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "蓄力横向剑气", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "three-to-four-hit-combo", @@ -1620,7 +2094,10 @@ "description": "A series of main slashes with delays between hits.", "telegraph": null, "counter": "Deflect each hit; once he finishes slashing he's open for a quick two hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "三至四连击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "thrust", @@ -1629,7 +2106,10 @@ "description": "A telegraphed thrust attack.", "telegraph": null, "counter": "Mikiri Counter - easy to react to once telegraphed.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "突刺", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "long-range-wind-attack", @@ -1638,7 +2118,10 @@ "description": "A long-range wind-based attack.", "telegraph": null, "counter": "Dodge by running sideways to gain a couple of free hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离风压攻击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "close-range-full-screen-sweep", @@ -1647,7 +2130,10 @@ "description": "A close-range sweep that covers the full screen.", "telegraph": null, "counter": "Sprint backwards before it goes off.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "近距离全屏横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "charged-double-projected-force", @@ -1656,7 +2142,10 @@ "description": "Makes two slashes that each launch a force disc. Deflecting the discs does not affect his posture.", "telegraph": "Sheathes his sword and charges the attack.", "counter": "The discs deal damage through block if not deflected properly, but the attack is easy to dodge.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "蓄力双重剑气", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "leaping-slam", @@ -1665,7 +2154,10 @@ "description": "When far away or the player is using an item, he leaps and slams with his spear, followed by a sword strike or gunshots.", "telegraph": "Leaps into the air before slamming down with his spear.", "counter": "Deflect the slam, then counter-attack.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "跳跃砸击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "retreating-sweep", @@ -1674,7 +2166,10 @@ "description": "Leaps backward into the air and sweeps with his spear, often following several blocked player attacks. Comes out very quickly.", "telegraph": "Leaps backward into the air.", "counter": "Deflect, or quickly dodge forward for a free hit.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "后撤横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "quad-shot", @@ -1683,7 +2178,10 @@ "description": "At a distance, fires several rapid pistol shots followed by a large thrust from the spear. The last bullet is slightly delayed.", "telegraph": "Pulls out a pistol.", "counter": "Learn to deflect the shots, then Mikiri counter the following thrust or jump-attack if it's a sweep. Simply blocking the bullets damages posture heavily.", - "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture." + "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture.", + "name_zh": "四连射击", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "lightning-slash", @@ -1692,7 +2190,10 @@ "description": "Launches lightning at the player with a sword swing.", "telegraph": "Jumps into the air as lightning converges on him.", "counter": "Jump into the air and use Lightning Reversal, Mist Raven, or Sakura Dance to redirect the attack back at him and stun him.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "雷电斩", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "lightning-overhead", @@ -1701,7 +2202,10 @@ "description": "Large overhead attacks with added lightning damage.", "telegraph": null, "counter": "Can be deflected, though the timing is difficult.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "雷电强化下劈", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "lightning-spear-sweep", @@ -1710,7 +2214,10 @@ "description": "A large swing with the spear covered in lightning.", "telegraph": null, "counter": "Get close when he does this and jump to deflect it directly back at him for big vitality and posture damage, plus a flurry of free hits.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "雷枪大横扫", + "name_zh_source": "translation", + "name_zh_source_url": null }, { "id": "distance-slash-lightning", @@ -1719,7 +2226,10 @@ "description": "More of a distance slash than the vertical Phase 1 wind attack.", "telegraph": null, "counter": "Similar avoidance strategy to the wind attacks - dodge by running sideways.", - "common_mistakes": null + "common_mistakes": null, + "name_zh": "远距离斩击(雷电)", + "name_zh_source": "translation", + "name_zh_source_url": null } ] } diff --git a/backend/tests/test_move_chinese_names.py b/backend/tests/test_move_chinese_names.py new file mode 100644 index 0000000..1601b83 --- /dev/null +++ b/backend/tests/test_move_chinese_names.py @@ -0,0 +1,77 @@ +"""Chinese move names: from a Chinese wiki (with its page) or marked as translations (V3 Milestone 4).""" + +import pytest +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.db.models import Boss, Move +from app.models.boss import BossMove +from app.seed import SeedError, load_boss_data, sync_reference_data + +WIKI_URL = "https://wiki.biligame.com/sekiro/example" + + +def test_every_seeded_move_has_a_chinese_name_with_a_valid_source(): + for boss in load_boss_data(): + for phase in boss.phases: + for move in phase.moves: + assert move.name_zh, f"{boss.id}/{move.id}" + assert move.name_zh_source in ("wiki", "translation") + if move.name_zh_source == "wiki": + assert move.name_zh_source_url.startswith("https://"), f"{boss.id}/{move.id}" + + +def test_seed_rejects_a_move_without_a_chinese_name(session): + data = load_boss_data() + move = data[0].phases[0].moves[0] + move.name_zh = None + move.name_zh_source = None + + with pytest.raises(SeedError, match="has no Chinese name"): + sync_reference_data(session, data) + + +@pytest.mark.parametrize( + "fields", + [ + {"name_zh": "突刺"}, + {"name_zh_source": "translation"}, + {"name_zh": "突刺", "name_zh_source": "wiki"}, + {"name_zh": "突刺", "name_zh_source": "translation", "name_zh_source_url": WIKI_URL}, + {"name_zh": "突刺", "name_zh_source": "official"}, + ], + ids=["name-without-source", "source-without-name", "wiki-without-page", "translation-with-page", "unknown-source"], +) +def test_move_model_rejects_inconsistent_chinese_name_sources(fields): + with pytest.raises(ValidationError): + BossMove(id="thrust", name="Thrust", move_type="thrust", **fields) + + +@pytest.mark.parametrize( + "fields", + [ + {"name_zh": "突刺", "name_zh_source": None}, + {"name_zh": "突刺", "name_zh_source": "wiki", "name_zh_source_url": None}, + {"name_zh": "突刺", "name_zh_source": "translation", "name_zh_source_url": WIKI_URL}, + {"name_zh": "突刺", "name_zh_source": "official"}, + ], + ids=["name-without-source", "wiki-without-page", "translation-with-page", "unknown-source"], +) +def test_database_rejects_inconsistent_chinese_name_sources(seeded_session, fields): + boss = seeded_session.scalar(select(Boss).where(Boss.slug == "genichiro-ashina")) + seeded_session.add(Move(boss=boss, slug="db-check", name="Check", move_type="thrust", **fields)) + + with pytest.raises(IntegrityError): + seeded_session.flush() + + +def test_boss_detail_returns_chinese_names_and_their_sources(anon_client): + boss = anon_client.get("/api/bosses/genichiro-ashina").json() + moves = {m["id"]: m for phase in boss["phases"] for m in phase["moves"]} + + assert moves["floating-passage"]["name_zh"] == "绝技·飞渡浮舟" + assert moves["floating-passage"]["name_zh_source"] == "wiki" + assert moves["floating-passage"]["name_zh_source_url"].startswith("https://wiki.biligame.com/") + assert moves["perilous-thrust"]["name_zh_source"] == "translation" + assert moves["perilous-thrust"]["name_zh_source_url"] is None diff --git a/backend/tests/test_sekiro_analytics.py b/backend/tests/test_sekiro_analytics.py index 6d7192a..50ba399 100644 --- a/backend/tests/test_sekiro_analytics.py +++ b/backend/tests/test_sekiro_analytics.py @@ -98,6 +98,7 @@ def test_recent_attempts_are_newest_first_with_names_and_capped_at_ten(client): "phase_reached": 2, "failure_move_id": "blood-scream", "failure_move_name": "Blood Scream", + "failure_move_name_zh": "血之咆哮", "failure_category": None, } assert [int(a["attempt_id"]) for a in recent] == sorted((int(a["attempt_id"]) for a in recent), reverse=True) diff --git a/frontend/src/components/AnalyticsPanel.test.tsx b/frontend/src/components/AnalyticsPanel.test.tsx index 5e57774..faf02be 100644 --- a/frontend/src/components/AnalyticsPanel.test.tsx +++ b/frontend/src/components/AnalyticsPanel.test.tsx @@ -13,14 +13,14 @@ const boss: Boss = { phase_number: 1, name: "Phase 1", moves: [ - { id: "perilous-thrust", name: "Perilous Thrust", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "perilous-thrust", name: "Perilous Thrust", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, { phase_number: 2, name: "Phase 2", moves: [ - { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, ], diff --git a/frontend/src/components/AttemptHistory.test.tsx b/frontend/src/components/AttemptHistory.test.tsx index 10a2b8f..c0b31fc 100644 --- a/frontend/src/components/AttemptHistory.test.tsx +++ b/frontend/src/components/AttemptHistory.test.tsx @@ -13,7 +13,7 @@ const boss: Boss = { phase_number: 2, name: "Phase 2", moves: [ - { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, ], diff --git a/frontend/src/components/ChineseMoveName.tsx b/frontend/src/components/ChineseMoveName.tsx new file mode 100644 index 0000000..bcd61c9 --- /dev/null +++ b/frontend/src/components/ChineseMoveName.tsx @@ -0,0 +1,31 @@ +import type { BossMove } from "../types"; + +const TRANSLATION_NOTE = "Translated for this app; not an official in-game name."; + +/** + * A move's Chinese name next to its English one. Names taken from a Chinese wiki + * link to that page; translations are labelled so they are never mistaken for + * official names. + */ +export default function ChineseMoveName({ move }: { move: BossMove }) { + if (!move.name_zh) return null; + + if (move.name_zh_source === "wiki" && move.name_zh_source_url) { + return ( + + + {move.name_zh} + + + ); + } + + return ( + + {move.name_zh} + + 译名 + + + ); +} diff --git a/frontend/src/components/MovesetReference.test.tsx b/frontend/src/components/MovesetReference.test.tsx index 83be176..4341f24 100644 --- a/frontend/src/components/MovesetReference.test.tsx +++ b/frontend/src/components/MovesetReference.test.tsx @@ -24,6 +24,9 @@ const boss: Boss = { telegraph: "A glint shows on the hilt.", counter: "Deflect the first wave.", common_mistakes: "Blocking the waves instead of deflecting them.", + name_zh: "秘传·龙闪", + name_zh_source: "wiki", + name_zh_source_url: "https://wiki.biligame.com/sekiro/dragon-flash", }, { id: "thrust", @@ -33,6 +36,9 @@ const boss: Boss = { telegraph: null, counter: null, common_mistakes: null, + name_zh: "突刺", + name_zh_source: "translation", + name_zh_source_url: null, }, ], }, @@ -52,7 +58,9 @@ describe("MovesetReference", () => { render(); const thrust = screen.getByText("Thrust").closest("li") as HTMLElement; - expect(thrust.textContent).toBe("Thrust"); + // Only the names: no description, telegraph, counter or common-mistake paragraphs. + expect(thrust.querySelectorAll("p")).toHaveLength(0); + expect(thrust.textContent).toBe("Thrust突刺译名"); }); it("links to the boss's data source", () => { @@ -66,4 +74,19 @@ describe("MovesetReference", () => { expect(screen.queryByText(/source:/i)).not.toBeInTheDocument(); }); + + it("links Chinese names taken from a wiki to their source page", () => { + render(); + + const link = screen.getByRole("link", { name: "秘传·龙闪" }); + expect(link).toHaveAttribute("href", "https://wiki.biligame.com/sekiro/dragon-flash"); + }); + + it("marks translated Chinese names as translations", () => { + render(); + + expect(screen.getByText("突刺")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "突刺" })).not.toBeInTheDocument(); + expect(screen.getByTitle(/not an official in-game name/)).toHaveTextContent("译名"); + }); }); diff --git a/frontend/src/components/MovesetReference.tsx b/frontend/src/components/MovesetReference.tsx index 94a4ae2..ef4b316 100644 --- a/frontend/src/components/MovesetReference.tsx +++ b/frontend/src/components/MovesetReference.tsx @@ -1,4 +1,5 @@ import type { Boss } from "../types"; +import ChineseMoveName from "./ChineseMoveName"; export default function MovesetReference({ boss }: { boss: Boss }) { return ( @@ -12,6 +13,7 @@ export default function MovesetReference({ boss }: { boss: Boss }) { {phase.moves.map((move) => (
  • {move.name} + {move.description &&

    {move.description}

    } {move.telegraph &&

    Telegraph: {move.telegraph}

    } {move.counter &&

    Counter: {move.counter}

    } diff --git a/frontend/src/components/RecordAttemptForm.test.tsx b/frontend/src/components/RecordAttemptForm.test.tsx index 83dc722..706df1d 100644 --- a/frontend/src/components/RecordAttemptForm.test.tsx +++ b/frontend/src/components/RecordAttemptForm.test.tsx @@ -18,14 +18,14 @@ const boss: Boss = { phase_number: 1, name: "Phase 1", moves: [ - { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, { phase_number: 2, name: "Phase 2", moves: [ - { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, ], diff --git a/frontend/src/components/RecordAttemptForm.tsx b/frontend/src/components/RecordAttemptForm.tsx index e9685fb..f4f7ce1 100644 --- a/frontend/src/components/RecordAttemptForm.tsx +++ b/frontend/src/components/RecordAttemptForm.tsx @@ -3,6 +3,7 @@ import type { FormEvent } from "react"; import { createAttempt } from "../api/attempts"; import { ApiError } from "../api/client"; import type { AttemptResult, Boss, CreateAttemptRequest } from "../types"; +import { moveLabel } from "../utils/moves"; const OTHER = "__other__"; const NOT_SURE = "__not_sure__"; @@ -106,7 +107,7 @@ export default function RecordAttemptForm({ boss, onSuccess, onCancel }: RecordA {movesForPhase.map((move) => ( ))} diff --git a/frontend/src/index.css b/frontend/src/index.css index 6ae9797..1fff0e2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -464,3 +464,24 @@ a { outline: 2px solid var(--color-primary-hover); outline-offset: 1px; } + +.move-name-zh { + margin-left: 0.5rem; + color: var(--color-text-muted); +} + +.move-name-zh a { + color: inherit; + text-decoration-style: dotted; +} + +.translation-badge { + margin-left: 0.35rem; + padding: 0 0.3rem; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 0.7rem; + text-decoration: none; + cursor: help; + vertical-align: middle; +} diff --git a/frontend/src/pages/BossDashboardPage.test.tsx b/frontend/src/pages/BossDashboardPage.test.tsx index be0c82d..761b5fb 100644 --- a/frontend/src/pages/BossDashboardPage.test.tsx +++ b/frontend/src/pages/BossDashboardPage.test.tsx @@ -23,7 +23,7 @@ const boss: Boss = { phase_number: 1, name: "Phase 1", moves: [ - { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, ], diff --git a/frontend/src/pages/SekiroDashboardPage.test.tsx b/frontend/src/pages/SekiroDashboardPage.test.tsx index a821c96..6986523 100644 --- a/frontend/src/pages/SekiroDashboardPage.test.tsx +++ b/frontend/src/pages/SekiroDashboardPage.test.tsx @@ -48,6 +48,7 @@ const analytics: SekiroAnalytics = { phase_reached: 2, failure_move_id: "owl-teleport", failure_move_name: "Owl Teleport", + failure_move_name_zh: "枭之瞬移", failure_category: null, }, { @@ -59,6 +60,7 @@ const analytics: SekiroAnalytics = { phase_reached: 3, failure_move_id: null, failure_move_name: null, + failure_move_name_zh: null, failure_category: null, }, ], diff --git a/frontend/src/pages/SekiroDashboardPage.tsx b/frontend/src/pages/SekiroDashboardPage.tsx index 59fd82d..c13df73 100644 --- a/frontend/src/pages/SekiroDashboardPage.tsx +++ b/frontend/src/pages/SekiroDashboardPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { getSekiroAnalytics } from "../api/sekiro"; +import { formatMoveName } from "../utils/moves"; import type { BossComparisonRow, RecentAttempt, SekiroAnalytics } from "../types"; type LoadState = "loading" | "error" | "ready"; @@ -17,7 +18,7 @@ function bestResult(row: BossComparisonRow): string { function attemptCause(attempt: RecentAttempt): string | null { if (attempt.result === "victory") return null; - if (attempt.failure_move_name) return attempt.failure_move_name; + if (attempt.failure_move_name) return formatMoveName(attempt.failure_move_name, attempt.failure_move_name_zh); if (attempt.failure_category === "other") return "Other"; return "Not Sure"; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 38a2dec..5f93cd1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -10,8 +10,14 @@ export interface BossMove { telegraph: string | null; counter: string | null; common_mistakes: string | null; + /** Chinese name: from a Chinese wiki ("wiki", see name_zh_source_url) or a translation. */ + name_zh: string | null; + name_zh_source: ChineseNameSource | null; + name_zh_source_url: string | null; } +export type ChineseNameSource = "wiki" | "translation"; + export interface BossPhase { phase_number: number; name: string; @@ -111,6 +117,7 @@ export interface RecentAttempt { phase_reached: number; failure_move_id: string | null; failure_move_name: string | null; + failure_move_name_zh: string | null; failure_category: FailureCategory | null; } diff --git a/frontend/src/utils/moves.test.ts b/frontend/src/utils/moves.test.ts index cf29fc8..a40184c 100644 --- a/frontend/src/utils/moves.test.ts +++ b/frontend/src/utils/moves.test.ts @@ -12,14 +12,14 @@ const boss: Boss = { phase_number: 1, name: "Phase 1", moves: [ - { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, { phase_number: 3, name: "Phase 3", moves: [ - { id: "lightning-attack", name: "Lightning Attack", move_type: "lightning", description: null, telegraph: null, counter: null, common_mistakes: null }, + { id: "lightning-attack", name: "Lightning Attack", move_type: "lightning", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, ], }, ], diff --git a/frontend/src/utils/moves.ts b/frontend/src/utils/moves.ts index 360766d..1edca32 100644 --- a/frontend/src/utils/moves.ts +++ b/frontend/src/utils/moves.ts @@ -1,10 +1,19 @@ -import type { Attempt, Boss } from "../types"; +import type { Attempt, Boss, BossMove } from "../types"; + +/** "English · 中文" when a Chinese name exists, for places that show a move as plain text. */ +export function formatMoveName(name: string, nameZh: string | null | undefined): string { + return nameZh ? `${name} · ${nameZh}` : name; +} + +export function moveLabel(move: Pick): string { + return formatMoveName(move.name, move.name_zh); +} export function getMoveName(boss: Boss, moveId: string | null): string | null { if (!moveId) return null; for (const phase of boss.phases) { const move = phase.moves.find((m) => m.id === moveId); - if (move) return move.name; + if (move) return moveLabel(move); } return moveId; } From bdd6d557e1bfe9602421be0083de93e0e692401e Mon Sep 17 00:00:00 2001 From: Yilun Wu Date: Wed, 23 Sep 2026 23:26:23 -0400 Subject: [PATCH 08/19] Add an English/Chinese interface (V3 Milestone 4, part 2) - language switch in the nav bar; saved on the account (users.preferred_language, PATCH /api/auth/me) or in the browser for visitors, defaulting to the browser language - every interface string in both languages via a small in-house dictionary and t(); each language shows only its own text - Chinese boss content for the 8 existing bosses: locations, phase names, and all 234 move text fields, translated from the sourced English; the seed requires a Chinese version for every English field - Chinese moveset shows one note about translations; wiki-sourced names link to their page - reviewed migration with a hand-added check constraint Co-Authored-By: Claude Opus 5.5 --- ...abd4d80_bilingual_content_and_language_.py | 48 + backend/app/db/models.py | 12 + backend/app/models/analytics.py | 1 + backend/app/models/auth.py | 9 + backend/app/models/boss.py | 7 + backend/app/routers/auth.py | 11 +- backend/app/seed.py | 22 + backend/app/services/auth_service.py | 6 + backend/app/services/boss_service.py | 11 +- .../app/services/game_analytics_service.py | 5 +- backend/seed/bosses.json | 1726 ++++++++++++----- backend/tests/test_auth.py | 19 + backend/tests/test_move_chinese_names.py | 43 + backend/tests/test_sekiro_analytics.py | 1 + .../integration-tests/attemptWorkflow.test.ts | 17 + frontend/src/api/auth.test.ts | 4 +- frontend/src/api/auth.ts | 4 + frontend/src/api/bosses.test.ts | 3 +- frontend/src/auth/AuthProvider.test.tsx | 6 +- frontend/src/auth/testAuth.tsx | 2 +- .../src/components/AnalyticsPanel.test.tsx | 7 +- frontend/src/components/AnalyticsPanel.tsx | 58 +- .../src/components/AttemptHistory.test.tsx | 4 +- frontend/src/components/AttemptHistory.tsx | 13 +- frontend/src/components/BossList.tsx | 14 +- frontend/src/components/ChineseMoveName.tsx | 31 - .../src/components/MovesetReference.test.tsx | 41 +- frontend/src/components/MovesetReference.tsx | 56 +- frontend/src/components/NavBar.tsx | 36 +- frontend/src/components/PasswordInput.tsx | 9 +- .../src/components/ProgressionChart.test.tsx | 5 +- frontend/src/components/ProgressionChart.tsx | 23 +- .../src/components/RecordAttemptForm.test.tsx | 7 +- frontend/src/components/RecordAttemptForm.tsx | 42 +- frontend/src/i18n/LanguageProvider.test.tsx | 104 + frontend/src/i18n/LanguageProvider.tsx | 73 + frontend/src/i18n/content.ts | 31 + frontend/src/i18n/language.ts | 46 + frontend/src/i18n/messages.ts | 294 +++ frontend/src/i18n/testLanguage.tsx | 11 + frontend/src/index.css | 35 +- frontend/src/main.tsx | 5 +- frontend/src/pages/AuthPage.tsx | 47 +- frontend/src/pages/BossDashboardPage.test.tsx | 4 +- frontend/src/pages/BossDashboardPage.tsx | 48 +- frontend/src/pages/BossSelectionPage.test.tsx | 18 +- frontend/src/pages/BossSelectionPage.tsx | 8 +- frontend/src/pages/HomePage.test.tsx | 2 +- frontend/src/pages/HomePage.tsx | 4 +- .../src/pages/SekiroDashboardPage.test.tsx | 27 +- frontend/src/pages/SekiroDashboardPage.tsx | 106 +- frontend/src/pages/WelcomePage.tsx | 17 +- frontend/src/types/index.ts | 11 + frontend/src/utils/moves.test.ts | 18 +- frontend/src/utils/moves.ts | 29 +- 55 files changed, 2427 insertions(+), 814 deletions(-) create mode 100644 backend/alembic/versions/4b0d6abd4d80_bilingual_content_and_language_.py delete mode 100644 frontend/src/components/ChineseMoveName.tsx create mode 100644 frontend/src/i18n/LanguageProvider.test.tsx create mode 100644 frontend/src/i18n/LanguageProvider.tsx create mode 100644 frontend/src/i18n/content.ts create mode 100644 frontend/src/i18n/language.ts create mode 100644 frontend/src/i18n/messages.ts create mode 100644 frontend/src/i18n/testLanguage.tsx diff --git a/backend/alembic/versions/4b0d6abd4d80_bilingual_content_and_language_.py b/backend/alembic/versions/4b0d6abd4d80_bilingual_content_and_language_.py new file mode 100644 index 0000000..6709780 --- /dev/null +++ b/backend/alembic/versions/4b0d6abd4d80_bilingual_content_and_language_.py @@ -0,0 +1,48 @@ +"""bilingual content and language preference + +Revision ID: 4b0d6abd4d80 +Revises: 01e9ec08d5ac +Create Date: 2026-09-23 23:11:42.011448 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4b0d6abd4d80' +down_revision: Union[str, Sequence[str], None] = '01e9ec08d5ac' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema: Chinese boss content and each user's interface language. + + Existing rows get NULLs; the next seed sync fills in the Chinese content. + """ + op.add_column('boss_phases', sa.Column('name_zh', sa.String(length=100), nullable=True)) + op.add_column('bosses', sa.Column('location_zh', sa.String(length=200), nullable=True)) + op.add_column('moves', sa.Column('description_zh', sa.Text(), nullable=True)) + op.add_column('moves', sa.Column('telegraph_zh', sa.Text(), nullable=True)) + op.add_column('moves', sa.Column('counter_zh', sa.Text(), nullable=True)) + op.add_column('moves', sa.Column('common_mistakes_zh', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('preferred_language', sa.String(length=5), nullable=True)) + # Autogenerate does not detect check constraints; this mirrors models.User. + op.create_check_constraint( + 'ck_users_preferred_language', 'users', "preferred_language IS NULL OR preferred_language IN ('en', 'zh')" + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('ck_users_preferred_language', 'users', type_='check') + op.drop_column('users', 'preferred_language') + op.drop_column('moves', 'common_mistakes_zh') + op.drop_column('moves', 'counter_zh') + op.drop_column('moves', 'telegraph_zh') + op.drop_column('moves', 'description_zh') + op.drop_column('bosses', 'location_zh') + op.drop_column('boss_phases', 'name_zh') diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 3e3bb95..fbf976d 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -35,6 +35,7 @@ class Boss(Base): name: Mapped[str] = mapped_column(String(200)) name_zh: Mapped[str | None] = mapped_column(String(200)) location: Mapped[str] = mapped_column(String(200)) + location_zh: Mapped[str | None] = mapped_column(String(200)) # Where this boss's phase and move data came from; moves share their boss's source. source_name: Mapped[str | None] = mapped_column(String(200)) source_url: Mapped[str | None] = mapped_column(Text) @@ -54,6 +55,7 @@ class BossPhase(Base): boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE")) phase_number: Mapped[int] = mapped_column(Integer) name: Mapped[str] = mapped_column(String(100)) + name_zh: Mapped[str | None] = mapped_column(String(100)) boss: Mapped[Boss] = relationship(back_populates="phases") move_links: Mapped[list["PhaseMove"]] = relationship( @@ -85,6 +87,11 @@ class Move(Base): telegraph: Mapped[str | None] = mapped_column(Text) counter: Mapped[str | None] = mapped_column(Text) common_mistakes: Mapped[str | None] = mapped_column(Text) + # Chinese versions of the text fields above: translations of the sourced English text. + description_zh: Mapped[str | None] = mapped_column(Text) + telegraph_zh: Mapped[str | None] = mapped_column(Text) + counter_zh: Mapped[str | None] = mapped_column(Text) + common_mistakes_zh: Mapped[str | None] = mapped_column(Text) # Chinese name: either the name used by a Chinese wiki ('wiki', with the page in # name_zh_source_url) or a translation ('translation'), never presented as official. name_zh: Mapped[str | None] = mapped_column(String(200)) @@ -149,12 +156,17 @@ class User(Base): __tablename__ = "users" __table_args__ = ( CheckConstraint("username = lower(username)", name="ck_users_username_lowercase"), + CheckConstraint( + "preferred_language IS NULL OR preferred_language IN ('en', 'zh')", name="ck_users_preferred_language" + ), ) id: Mapped[int] = mapped_column(primary_key=True) # Stored lowercase so usernames are unique regardless of case. username: Mapped[str] = mapped_column(String(30), unique=True) password_hash: Mapped[str] = mapped_column(String(255)) + # Interface language chosen in the app; null until the user picks one. + preferred_language: Mapped[str | None] = mapped_column(String(5)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) sessions: Mapped[list["UserSession"]] = relationship(back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py index 04ec0ca..445ce3e 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -54,6 +54,7 @@ class RecentAttempt(BaseModel): failure_move_id: str | None = None failure_move_name: str | None = None failure_move_name_zh: str | None = None + boss_name_zh: str | None = None failure_category: FailureCategory | None = None diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 80aee60..c56de41 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -1,4 +1,5 @@ import re +from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -28,6 +29,14 @@ class LoginRequest(BaseModel): password: str = Field(max_length=128) +Language = Literal["en", "zh"] + + class User(BaseModel): id: str username: str + preferred_language: Language | None = None + + +class UpdateUserRequest(BaseModel): + preferred_language: Language diff --git a/backend/app/models/boss.py b/backend/app/models/boss.py index f956ce8..2d23748 100644 --- a/backend/app/models/boss.py +++ b/backend/app/models/boss.py @@ -12,6 +12,10 @@ class BossMove(BaseModel): counter: str | None = None common_mistakes: str | None = None name_zh: str | None = None + description_zh: str | None = None + telegraph_zh: str | None = None + counter_zh: str | None = None + common_mistakes_zh: str | None = None # "wiki": the name used by a Chinese wiki, see name_zh_source_url. # "translation": translated for this app; not an official name. name_zh_source: Literal["wiki", "translation"] | None = None @@ -29,6 +33,7 @@ def check_chinese_name_source(self) -> "BossMove": class BossPhase(BaseModel): phase_number: int name: str + name_zh: str | None = None moves: list[BossMove] = [] @@ -38,6 +43,7 @@ class Boss(BaseModel): name_zh: str | None = None game: str location: str + location_zh: str | None = None phases: list[BossPhase] source_name: str | None = None source_url: str | None = None @@ -48,3 +54,4 @@ class BossSummary(BaseModel): name: str name_zh: str | None = None location: str + location_zh: str | None = None diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 69423f6..b536382 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -4,14 +4,14 @@ from app.auth import SESSION_COOKIE, clear_session_cookie, get_current_user, set_session_cookie from app.db import models from app.db.session import get_db -from app.models.auth import LoginRequest, RegisterRequest, User +from app.models.auth import LoginRequest, RegisterRequest, UpdateUserRequest, User from app.services import auth_service router = APIRouter(prefix="/api/auth", tags=["auth"]) def _to_response(user: models.User) -> User: - return User(id=str(user.id), username=user.username) + return User(id=str(user.id), username=user.username, preferred_language=user.preferred_language) @router.post("/register", response_model=User, status_code=status.HTTP_201_CREATED) @@ -48,3 +48,10 @@ def logout( @router.get("/me", response_model=User) def me(user: models.User = Depends(get_current_user)): return _to_response(user) + + +@router.patch("/me", response_model=User) +def update_me( + req: UpdateUserRequest, user: models.User = Depends(get_current_user), db: Session = Depends(get_db) +): + return _to_response(auth_service.set_preferred_language(db, user, req.preferred_language)) diff --git a/backend/app/seed.py b/backend/app/seed.py index fea609b..4154176 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -26,8 +26,15 @@ "name_zh", "name_zh_source", "name_zh_source_url", + "description_zh", + "telegraph_zh", + "counter_zh", + "common_mistakes_zh", ) +# Every English text field that has a value needs its Chinese version. +TRANSLATED_MOVE_FIELDS = ("description", "telegraph", "counter", "common_mistakes") + class SeedError(ValueError): pass @@ -57,6 +64,12 @@ def _validate(bosses: list[BossData]) -> None: if boss.game not in GAMES: errors.append(f"boss '{boss.id}' references unknown game '{boss.game}'") + if boss.location_zh is None: + errors.append(f"boss '{boss.id}' has no Chinese location (location_zh)") + for phase in boss.phases: + if phase.name_zh is None: + errors.append(f"boss '{boss.id}' phase {phase.phase_number} has no Chinese name (name_zh)") + numbers = [p.phase_number for p in boss.phases] if len(numbers) != len(set(numbers)): errors.append(f"boss '{boss.id}' has duplicate phase numbers") @@ -69,6 +82,13 @@ def _validate(bosses: list[BossData]) -> None: for move in phase.moves: if move.name_zh is None: errors.append(f"boss '{boss.id}' move '{move.id}' has no Chinese name (name_zh)") + for field_name in TRANSLATED_MOVE_FIELDS: + has_english = getattr(move, field_name) is not None + has_chinese = getattr(move, f"{field_name}_zh") is not None + if has_english != has_chinese: + errors.append( + f"boss '{boss.id}' move '{move.id}': {field_name} and {field_name}_zh must both be set or both be empty" + ) data = move.model_dump(include=set(MOVE_FIELDS)) if move.id not in first_seen: first_seen[move.id] = (phase.phase_number, data) @@ -118,6 +138,7 @@ def sync_reference_data(session: Session, bosses: list[BossData]) -> SyncReport: boss.name = data.name boss.name_zh = data.name_zh boss.location = data.location + boss.location_zh = data.location_zh boss.source_name = data.source_name boss.source_url = data.source_url report.bosses += 1 @@ -147,6 +168,7 @@ def sync_reference_data(session: Session, bosses: list[BossData]) -> SyncReport: phase = BossPhase(phase_number=phase_data.phase_number) boss.phases.append(phase) phase.name = phase_data.name + phase.name_zh = phase_data.name_zh report.phases += 1 phase.move_links.clear() diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 73b996d..b2bfd6b 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -65,6 +65,12 @@ def authenticate(db: Session, username: str, password: str) -> User | None: return user +def set_preferred_language(db: Session, user: User, language: str) -> User: + user.preferred_language = language + db.commit() + return user + + def create_session(db: Session, user: User) -> str: """Starts a session for the user and returns the raw token for the cookie.""" db.execute(delete(UserSession).where(UserSession.user_id == user.id, UserSession.expires_at <= _now())) diff --git a/backend/app/services/boss_service.py b/backend/app/services/boss_service.py index 72da43f..bbe5de1 100644 --- a/backend/app/services/boss_service.py +++ b/backend/app/services/boss_service.py @@ -7,7 +7,10 @@ def get_all_bosses(session: Session) -> list[BossSummary]: rows = session.scalars(select(db.Boss).order_by(db.Boss.id)) - return [BossSummary(id=b.slug, name=b.name, name_zh=b.name_zh, location=b.location) for b in rows] + return [ + BossSummary(id=b.slug, name=b.name, name_zh=b.name_zh, location=b.location, location_zh=b.location_zh) + for b in rows + ] def get_boss_row(session: Session, boss_slug: str) -> db.Boss | None: @@ -31,12 +34,14 @@ def get_boss(session: Session, boss_slug: str) -> Boss | None: name_zh=boss.name_zh, game=boss.game.slug, location=boss.location, + location_zh=boss.location_zh, source_name=boss.source_name, source_url=boss.source_url, phases=[ BossPhase( phase_number=phase.phase_number, name=phase.name, + name_zh=phase.name_zh, moves=[ BossMove( id=link.move.slug, @@ -47,6 +52,10 @@ def get_boss(session: Session, boss_slug: str) -> Boss | None: counter=link.move.counter, common_mistakes=link.move.common_mistakes, name_zh=link.move.name_zh, + description_zh=link.move.description_zh, + telegraph_zh=link.move.telegraph_zh, + counter_zh=link.move.counter_zh, + common_mistakes_zh=link.move.common_mistakes_zh, name_zh_source=link.move.name_zh_source, name_zh_source_url=link.move.name_zh_source_url, ) diff --git a/backend/app/services/game_analytics_service.py b/backend/app/services/game_analytics_service.py index 5314b98..c9aaae9 100644 --- a/backend/app/services/game_analytics_service.py +++ b/backend/app/services/game_analytics_service.py @@ -88,7 +88,7 @@ def _boss_rows(session: Session, user_id: int) -> list[BossComparisonRow]: def _recent_attempts(session: Session, user_id: int) -> list[RecentAttempt]: rows = session.execute( - select(db.Attempt, db.Boss.slug, db.Boss.name, db.Move.slug, db.Move.name, db.Move.name_zh) + select(db.Attempt, db.Boss.slug, db.Boss.name, db.Boss.name_zh, db.Move.slug, db.Move.name, db.Move.name_zh) .join(db.Boss, db.Attempt.boss_id == db.Boss.id) .outerjoin(db.Move, db.Attempt.failure_move_id == db.Move.id) .where(db.Attempt.user_id == user_id) @@ -100,6 +100,7 @@ def _recent_attempts(session: Session, user_id: int) -> list[RecentAttempt]: attempt_id=str(attempt.id), boss_id=boss_slug, boss_name=boss_name, + boss_name_zh=boss_name_zh, timestamp=attempt.created_at, result=AttemptResult(attempt.result), phase_reached=attempt.phase_reached, @@ -108,7 +109,7 @@ def _recent_attempts(session: Session, user_id: int) -> list[RecentAttempt]: failure_move_name_zh=move_name_zh, failure_category=FailureCategory(attempt.failure_category) if attempt.failure_category else None, ) - for attempt, boss_slug, boss_name, move_slug, move_name, move_name_zh in rows + for attempt, boss_slug, boss_name, boss_name_zh, move_slug, move_name, move_name_zh in rows ] diff --git a/backend/seed/bosses.json b/backend/seed/bosses.json index e57a300..bba7095 100644 --- a/backend/seed/bosses.json +++ b/backend/seed/bosses.json @@ -5,444 +5,588 @@ "name_zh": "苇名弦一郎", "game": "sekiro", "location": "Ashina Castle - Castle Tower Lookout", + "location_zh": "苇名城 - 天守阁瞭望台", "source_name": "Fandom Sekiro Wiki", "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Genichiro_Ashina,_Second_Encounter", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "perilous-attack-follow-up", "name": "Perilous Attack Follow Up", + "name_zh": "危攻击后续追击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Delivers an overhead smash while airborne and connects with a Perilous Thrust upon landing.", + "description_zh": "跃起在空中向下重砸,落地后接一记危·突刺。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "In this phase he always follows with a Perilous Thrust, opening him up to an easy Mikiri Counter.", + "counter_zh": "这个阶段他一定会接危·突刺,正好可以轻松识破。", "common_mistakes": null, - "name_zh": "危攻击后续追击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-attack", "name": "Perilous Attack", + "name_zh": "危攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Runs towards Wolf and does a plunging attack.", + "description_zh": "冲向狼,然后发动一记下落攻击。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-grab", "name": "Perilous Grab", + "name_zh": "危·投技", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "A sideways jumping slash to the left, then a quick elbow jab; if it connects, he punches Wolf in the stomach.", + "description_zh": "向左侧跳斩,接着迅速用手肘一顶;如果命中,他会一拳打在狼的腹部。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Striking him during this attack topples his balance, even with a visible block at the end of the move.", + "counter_zh": "在这招期间攻击他能打乱他的平衡,即使招式结尾他明显在防御也一样。", "common_mistakes": null, - "name_zh": "危·投技", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-thrust", "name": "Perilous Thrust", + "name_zh": "危·突刺", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Thrusts with his katana.", + "description_zh": "用太刀突刺。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Mikiri Counter.", + "counter_zh": "识破。", "common_mistakes": null, - "name_zh": "危·突刺", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-sweep", "name": "Perilous Sweep", + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Sweeps the floor and follows up with an arrow shot.", + "description_zh": "贴地横扫,接着射出一箭。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "advance", "name": "Advance", + "name_zh": "突进", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two horizontal slashes while stepping forth.", + "description_zh": "向前踏步的同时横斩两次。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "突进", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "chasing-slice", "name": "Chasing Slice", + "name_zh": "追击斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Runs a short distance, then slashes twice.", + "description_zh": "跑出一小段距离,然后斩击两次。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "追击斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "closing-tide", "name": "Closing Tide", + "name_zh": "涨潮", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Slides and shoots one or two arrows, then rolls forward and slashes once. In Phase 1, it is always his first attack.", + "description_zh": "滑步射出一到两支箭,然后向前翻滚斩击一次。第一阶段他的第一招一定是这招。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "涨潮", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "floating-passage", "name": "Floating Passage", + "name_zh": "绝技·飞渡浮舟", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F", "move_type": "combo", "description": "A deadly seven-hit combo ending in a guard-breaking strike. If the target is too far away, he may cancel it at any time into an arrow shot.", + "description_zh": "致命的七连击,最后一击能破防。如果目标离得太远,他可能随时中断连击改为射箭。", "telegraph": "Starts with him sliding to the left.", + "telegraph_zh": "起手时他会向左滑步。", "counter": "If your posture bar is almost full, deflect the first two hits, then dodge away and punish him once he finishes.", + "counter_zh": "如果你的躯干快满了,先弹开前两击,然后闪避拉开距离,等他打完再反击。", "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot.", - "name_zh": "绝技·飞渡浮舟", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F" + "common_mistakes_zh": "连击过程中离他太远——他会用破防射击打断连击。" }, { "id": "guard-breaking-shot", "name": "Guard Breaking Shot", + "name_zh": "破防射击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Notches an arrow with incredible force, capable of breaking guard when released.", + "description_zh": "以极大的力道拉弓,射出后能破防。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot.", - "name_zh": "破防射击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "在他面前回复——你每次使用伤药葫芦或其他回复道具,他都会用这一箭回应。" }, { "id": "hawk-shot", "name": "Hawk Shot", + "name_zh": "鹰射", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Stays airborne and shoots rapidly four times.", + "description_zh": "停在空中快速连射四箭。", "telegraph": "Always dashes backward first.", + "telegraph_zh": "起手前一定会先向后冲刺。", "counter": "Interrupt the move as it starts, or dodge all the arrows by dashing twice toward him.", + "counter_zh": "在起手时打断这招,或者朝他冲刺两次躲开所有箭矢。", "common_mistakes": null, - "name_zh": "鹰射", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "iaijutsu", "name": "Iaijutsu", + "name_zh": "居合斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A quick drawn strike with a short closing distance.", + "description_zh": "一记快速的拔刀斩,突进距离较短。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "居合斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "receding-tide", "name": "Receding Tide", + "name_zh": "退潮", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Slides back and shoots two arrows at the same time, then prepares for an Iaijutsu.", + "description_zh": "向后滑步,同时射出两支箭,然后准备居合斩。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "退潮", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "two-hit-combo", "name": "Two-hit Combo", + "name_zh": "二连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Spins his body, slashing, then kicks right after. May follow up with a Guard Breaking Shot.", + "description_zh": "转身斩击,紧接着踢一脚。之后可能接破防射击。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "二连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "perilous-attack-follow-up-phase-2", "name": "Perilous Attack Follow Up (Phase 2)", + "name_zh": "危攻击后续追击(第二阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Delivers an overhead smash while airborne and connects with a Perilous Thrust or a Perilous Sweep upon landing.", + "description_zh": "跃起在空中向下重砸,落地后接危·突刺或危·下段横扫。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危攻击后续追击(第二阶段)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-attack", "name": "Perilous Attack", + "name_zh": "危攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Runs towards Wolf and does a plunging attack.", + "description_zh": "冲向狼,然后发动一记下落攻击。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-grab", "name": "Perilous Grab", + "name_zh": "危·投技", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "A sideways jumping slash to the left, then a quick elbow jab; if it connects, he punches Wolf in the stomach.", + "description_zh": "向左侧跳斩,接着迅速用手肘一顶;如果命中,他会一拳打在狼的腹部。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Striking him during this attack topples his balance, even with a visible block at the end of the move.", + "counter_zh": "在这招期间攻击他能打乱他的平衡,即使招式结尾他明显在防御也一样。", "common_mistakes": null, - "name_zh": "危·投技", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-thrust", "name": "Perilous Thrust", + "name_zh": "危·突刺", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Thrusts with his katana.", + "description_zh": "用太刀突刺。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Mikiri Counter.", + "counter_zh": "识破。", "common_mistakes": null, - "name_zh": "危·突刺", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-sweep", "name": "Perilous Sweep", + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Sweeps the floor and follows up with an arrow shot.", + "description_zh": "贴地横扫,接着射出一箭。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "advance", "name": "Advance", + "name_zh": "突进", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two horizontal slashes while stepping forth.", + "description_zh": "向前踏步的同时横斩两次。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "突进", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "chasing-slice", "name": "Chasing Slice", + "name_zh": "追击斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Runs a short distance, then slashes twice.", + "description_zh": "跑出一小段距离,然后斩击两次。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "追击斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "closing-tide", "name": "Closing Tide", + "name_zh": "涨潮", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Slides and shoots one or two arrows, then rolls forward and slashes once. In Phase 1, it is always his first attack.", + "description_zh": "滑步射出一到两支箭,然后向前翻滚斩击一次。第一阶段他的第一招一定是这招。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "涨潮", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "floating-passage", "name": "Floating Passage", + "name_zh": "绝技·飞渡浮舟", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F", "move_type": "combo", "description": "A deadly seven-hit combo ending in a guard-breaking strike. If the target is too far away, he may cancel it at any time into an arrow shot.", + "description_zh": "致命的七连击,最后一击能破防。如果目标离得太远,他可能随时中断连击改为射箭。", "telegraph": "Starts with him sliding to the left.", + "telegraph_zh": "起手时他会向左滑步。", "counter": "If your posture bar is almost full, deflect the first two hits, then dodge away and punish him once he finishes.", + "counter_zh": "如果你的躯干快满了,先弹开前两击,然后闪避拉开距离,等他打完再反击。", "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot.", - "name_zh": "绝技·飞渡浮舟", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F" + "common_mistakes_zh": "连击过程中离他太远——他会用破防射击打断连击。" }, { "id": "guard-breaking-shot", "name": "Guard Breaking Shot", + "name_zh": "破防射击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Notches an arrow with incredible force, capable of breaking guard when released.", + "description_zh": "以极大的力道拉弓,射出后能破防。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot.", - "name_zh": "破防射击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "在他面前回复——你每次使用伤药葫芦或其他回复道具,他都会用这一箭回应。" }, { "id": "hawk-shot", "name": "Hawk Shot", + "name_zh": "鹰射", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Stays airborne and shoots rapidly four times.", + "description_zh": "停在空中快速连射四箭。", "telegraph": "Always dashes backward first.", + "telegraph_zh": "起手前一定会先向后冲刺。", "counter": "Interrupt the move as it starts, or dodge all the arrows by dashing twice toward him.", + "counter_zh": "在起手时打断这招,或者朝他冲刺两次躲开所有箭矢。", "common_mistakes": null, - "name_zh": "鹰射", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "iaijutsu", "name": "Iaijutsu", + "name_zh": "居合斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A quick drawn strike with a short closing distance.", + "description_zh": "一记快速的拔刀斩,突进距离较短。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "居合斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "receding-tide", "name": "Receding Tide", + "name_zh": "退潮", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Slides back and shoots two arrows at the same time, then prepares for an Iaijutsu.", + "description_zh": "向后滑步,同时射出两支箭,然后准备居合斩。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "退潮", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "two-hit-combo", "name": "Two-hit Combo", + "name_zh": "二连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Spins his body, slashing, then kicks right after. May follow up with a Guard Breaking Shot.", + "description_zh": "转身斩击,紧接着踢一脚。之后可能接破防射击。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "二连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 3, "name": "Phase 3", + "name_zh": "第三阶段", "moves": [ { "id": "deaths-thrust", "name": "Death's Thrust", + "name_zh": "死亡突刺", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Lunges forward, thrusting over a long distance. He always opens Phase 3 with this move. May follow up with a Perilous Grab.", + "description_zh": "向前扑刺,突刺距离很长。第三阶段他一定以这招开场。之后可能接危·投技。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": "Using an item - he answers it with this thrust.", - "name_zh": "死亡突刺", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "使用道具——他会用这记突刺回应。" }, { "id": "floating-passage-phase-3", "name": "Floating Passage (Phase 3)", + "name_zh": "绝技·飞渡浮舟(第三阶段)", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F", "move_type": "combo", "description": "A deadly seven-hit combo ending in a guard-breaking strike, followed by a delayed Perilous Thrust. If the target is too far away, he may cancel it at any time into an arrow shot.", + "description_zh": "致命的七连击,最后一击能破防,之后还有一记延迟的危·突刺。如果目标离得太远,他可能随时中断连击改为射箭。", "telegraph": "Perilous Attack kanji. Starts with him sliding to the left.", + "telegraph_zh": "“危”字提示。起手时他会向左滑步。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "绝技·飞渡浮舟(第三阶段)", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E9%A3%9E%E6%B8%A1%E6%B5%AE%E8%88%9F" + "common_mistakes_zh": null }, { "id": "perilous-attack-follow-up-phase-3", "name": "Perilous Attack Follow Up (Phase 3)", + "name_zh": "危攻击后续追击(第三阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Delivers an overhead smash while airborne, then one of three follow-ups: a Perilous Thrust, a Perilous Sweep, or a quick upward cut that, if deflected, is followed by a delayed Perilous Thrust.", + "description_zh": "跃起在空中向下重砸,然后接三种后续之一:危·突刺、危·下段横扫,或者一记快速上挑;上挑被弹开后,他会再接一记延迟的危·突刺。", "telegraph": "Perilous Attack kanji. After landing: crouching down means a Perilous Thrust, turning clockwise means a Perilous Sweep, turning anti-clockwise means the quick upward cut.", + "telegraph_zh": "“危”字提示。落地后:蹲下是危·突刺,顺时针转身是危·下段横扫,逆时针转身是快速上挑。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危攻击后续追击(第三阶段)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-grab-phase-3", "name": "Perilous Grab (Phase 3)", + "name_zh": "危·投技(第三阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Runs toward Wolf attempting to grab him. If it connects, he throws Wolf to the ground and stabs him in the chest.", + "description_zh": "冲向狼试图抓住他。如果命中,会把狼摔在地上并刺穿胸口。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "危·投技(第三阶段)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "lightning-of-tomoe-shot", "name": "Lightning of Tomoe Shot", + "name_zh": "巴之雷·射击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "lightning", "description": "Shoots an electrifying arrow that instantly inflicts Shock.", + "description_zh": "射出一支带电的箭,立即造成感电。", "telegraph": "Jumps up and imbues his arrow with lightning.", + "telegraph_zh": "跃起,让箭矢缠上雷电。", "counter": "Lightning Reversal sends it back for significant posture and vitality damage and briefly paralyzes him. His lightning attacks can also be interrupted by hitting him, or avoided by dashing under him.", + "counter_zh": "用雷反把雷电返还给他,能造成大量躯干和体力伤害,并让他短暂麻痹。他的雷电攻击也可以通过攻击他来打断,或者冲刺到他身下躲开。", "common_mistakes": null, - "name_zh": "巴之雷·射击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "lightning-of-tomoe-slash", "name": "Lightning of Tomoe Slash", + "name_zh": "巴之雷·斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "lightning", "description": "Leaps up, then slashes forward in a long horizontal arc of lightning.", + "description_zh": "跃起,然后向前挥出一道长长的横向雷电弧。", "telegraph": "Raises his katana to his right with both hands as it is imbued with lightning.", + "telegraph_zh": "双手把太刀举到右侧,刀身缠上雷电。", "counter": "Lightning Reversal sends it back for significant posture and vitality damage and briefly paralyzes him. His lightning attacks can also be interrupted by hitting him, or avoided by dashing under him.", + "counter_zh": "用雷反把雷电返还给他,能造成大量躯干和体力伤害,并让他短暂麻痹。他的雷电攻击也可以通过攻击他来打断,或者冲刺到他身下躲开。", "common_mistakes": null, - "name_zh": "巴之雷·斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "lightning-of-tomoe-smash", "name": "Lightning of Tomoe Smash", + "name_zh": "巴之雷·砸击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "lightning", "description": "Leaps up, then smashes straight down with lightning.", + "description_zh": "跃起,然后带着雷电垂直向下重砸。", "telegraph": "Raises his katana to his left as it is imbued with lightning.", + "telegraph_zh": "把太刀举到左侧,刀身缠上雷电。", "counter": "Lightning Reversal sends it back for significant posture and vitality damage and briefly paralyzes him. His lightning attacks can also be interrupted by hitting him, or avoided by dashing under him.", + "counter_zh": "用雷反把雷电返还给他,能造成大量躯干和体力伤害,并让他短暂麻痹。他的雷电攻击也可以通过攻击他来打断,或者冲刺到他身下躲开。", "common_mistakes": null, - "name_zh": "巴之雷·砸击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -454,366 +598,485 @@ "name_zh": "义父", "game": "sekiro", "location": "Hirata Estate Revisited - Hidden Temple", + "location_zh": "平田宅邸(回忆)- 佛殿", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Owl+(Father)", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "zig-zag", "name": "Zig-Zag", + "name_zh": "之字形移动", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "Many of Owl's attacks begin with a zig-zag motion designed to confuse the player, leading into one of five possible follow-up attacks.", + "description_zh": "义父的很多攻击都以之字形移动起手,用来迷惑玩家,之后会接五种攻击之一。", "telegraph": null, + "telegraph_zh": null, "counter": "Stay ready to react to whichever attack follows.", + "counter_zh": "随时准备应对接下来的攻击。", "common_mistakes": null, - "name_zh": "之字形移动", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shuriken-throw", "name": "Shuriken Throw", + "name_zh": "投掷手里剑", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "A single shuriken projectile attack.", + "description_zh": "投掷一枚手里剑。", "telegraph": null, + "telegraph_zh": null, "counter": "Block, deflect, or dodge to the side.", + "counter_zh": "防御、弹刀,或者向侧面闪避。", "common_mistakes": null, - "name_zh": "投掷手里剑", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "double-shuriken-flip", "name": "Double Shuriken + Flip", + "name_zh": "双手里剑 + 后翻", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two shuriken throws followed by an aerial flip and downward sword strike.", + "description_zh": "投掷两枚手里剑,然后空翻并向下挥刀。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect/block the shurikens and dodge sideways to avoid the flip, then counter.", + "counter_zh": "弹开或防御手里剑,再向侧面闪避躲开空翻斩,然后反击。", "common_mistakes": null, - "name_zh": "双手里剑 + 后翻", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shadowfall", "name": "Shadowfall", + "name_zh": "秘传·巨型忍者落杀", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E5%B7%A8%E5%9E%8B%E5%BF%8D%E8%80%85%E8%90%BD%E6%9D%80", "move_type": "thrust", "description": "Unblockable thrust; if it hits, he jumps off and comes down with two huge slashes.", + "description_zh": "无法防御的突刺;如果命中,他会跳起再落下,接两记巨大的斩击。", "telegraph": "Charges up before the unblockable thrust.", + "telegraph_zh": "无法防御的突刺前会蓄力。", "counter": "Mikiri the thrust.", + "counter_zh": "识破这记突刺。", "common_mistakes": null, - "name_zh": "秘传·巨型忍者落杀", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E5%B7%A8%E5%9E%8B%E5%BF%8D%E8%80%85%E8%90%BD%E6%9D%80" + "common_mistakes_zh": null }, { "id": "shuriken-slash", "name": "Shuriken Slash", + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Throws a shuriken immediately followed by a running slash.", + "description_zh": "投掷一枚手里剑,紧接着冲刺斩。", "telegraph": null, + "telegraph_zh": null, "counter": "Block/deflect both. A well-timed dodge to the right is also possible.", + "counter_zh": "两下都防御或弹开。时机准确的话,也可以向右闪避。", "common_mistakes": null, - "name_zh": "手里剑 + 斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-sweep", "name": "Perilous Sweep", + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Unblockable sweep attack performed from the side.", + "description_zh": "从侧面发动的无法防御的横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it and stay close to bait his overhead slash, or jump back for a jump kick to the head.", + "counter_zh": "跳过去并留在他身边,引诱他出下劈;或者向后跳,踩头反击。", "common_mistakes": null, - "name_zh": "危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "large-overhead-slash", "name": "Large Overhead Slash", + "name_zh": "大幅度下劈", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "A very powerful overhead slash that adapts based on the player's response.", + "description_zh": "威力极大的下劈,会根据玩家的反应改变出招。", "telegraph": "Loads up for the overhead slash.", + "telegraph_zh": "蓄力准备下劈。", "counter": "Dodge at the last moment and counter, or step back out of range. Hits can land during the charge-up.", + "counter_zh": "在最后一刻闪避再反击,或者后退到范围外。他蓄力期间可以打中他。", "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack.", - "name_zh": "大幅度下劈", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "闪避太早会让他改成一记快速横斩。" }, { "id": "backflip", "name": "Backflip", + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "Leaps away; deals no vitality damage but significant posture damage.", + "description_zh": "向后跃开;不造成体力伤害,但会造成大量躯干伤害。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect to minimize posture damage.", + "counter_zh": "弹刀以尽量减少躯干伤害。", "common_mistakes": null, - "name_zh": "后空翻", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "firecrackers", "name": "Firecrackers", + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Throws blinding firecrackers followed by an explosion; can chain into Shuriken Throw, Shadowfall, or Shuriken Slash.", + "description_zh": "扔出致盲的爆竹并引发爆炸;之后可能接投掷手里剑、秘传·巨型忍者落杀或手里剑 + 斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge backwards to avoid.", + "counter_zh": "向后闪避躲开。", "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you.", - "name_zh": "爆竹", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "他扔出爆竹后闪避得太快,会被他接下来的斩击命中。" }, { "id": "running-slash", "name": "Running Slash", + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A single strong slash performed while approaching the player.", + "description_zh": "接近玩家时发动的一记强力斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Block/deflect. Dodging to the left is also possible.", + "counter_zh": "防御或弹刀。也可以向左闪避。", "common_mistakes": null, - "name_zh": "冲刺斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "five-slash-firecracker-combo", "name": "5-Slash Firecracker Combo", + "name_zh": "五连斩接爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Three consecutive sword slashes, followed by an elbow strike, followed by firecrackers that he slashes through.", + "description_zh": "连续三次挥刀,接一记肘击,然后扔出爆竹并挥刀穿过。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect/block the slashes, then dodge right when the firecrackers are thrown for a counter opportunity.", + "counter_zh": "弹开或防御前几刀,在他扔出爆竹时向右闪避,寻找反击机会。", "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival.", - "name_zh": "五连斩接爆竹", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "被这套连击逼进角落,那里几乎没有活路。" }, { "id": "double-slash-perilous-sweep", "name": "Double Slash into Perilous Sweep", + "name_zh": "二连斩接危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two consecutive slashes ending in an unblockable sweep; can chain into Large Overhead Slash.", + "description_zh": "连续两次斩击,最后接一记无法防御的横扫;之后可能接大幅度下劈。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it and stay close to bait his overhead slash.", + "counter_zh": "跳过去并留在他身边,引诱他出下劈。", "common_mistakes": null, - "name_zh": "二连斩接危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "stomp-deathblow", "name": "Stomp (Deathblow)", + "name_zh": "踩踏(忍杀)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Raises a foot then stomps down, pinning the player and stabbing through the chest for massive damage. Cannot be blocked and gives no Perilous indicator.", + "description_zh": "抬起一只脚然后踩下,把玩家踩住并刺穿胸口,造成大量伤害。无法防御,也没有“危”字提示。", "telegraph": "Raises his foot into the air before stomping down. There is no Perilous indicator.", + "telegraph_zh": "踩下前会把脚高高抬起。没有“危”字提示。", "counter": "Dodge backwards or safety roll to avoid it.", + "counter_zh": "向后闪避或受身翻滚躲开。", "common_mistakes": null, - "name_zh": "踩踏(忍杀)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "zig-zag", "name": "Zig-Zag", + "name_zh": "之字形移动", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "Many of Owl's attacks begin with a zig-zag motion designed to confuse the player, leading into one of five possible follow-up attacks.", + "description_zh": "义父的很多攻击都以之字形移动起手,用来迷惑玩家,之后会接五种攻击之一。", "telegraph": null, + "telegraph_zh": null, "counter": "Stay ready to react to whichever attack follows.", + "counter_zh": "随时准备应对接下来的攻击。", "common_mistakes": null, - "name_zh": "之字形移动", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shuriken-throw", "name": "Shuriken Throw", + "name_zh": "投掷手里剑", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "A single shuriken projectile attack.", + "description_zh": "投掷一枚手里剑。", "telegraph": null, + "telegraph_zh": null, "counter": "Block, deflect, or dodge to the side.", + "counter_zh": "防御、弹刀,或者向侧面闪避。", "common_mistakes": null, - "name_zh": "投掷手里剑", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "double-shuriken-flip", "name": "Double Shuriken + Flip", + "name_zh": "双手里剑 + 后翻", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two shuriken throws followed by an aerial flip and downward sword strike.", + "description_zh": "投掷两枚手里剑,然后空翻并向下挥刀。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect/block the shurikens and dodge sideways to avoid the flip, then counter.", + "counter_zh": "弹开或防御手里剑,再向侧面闪避躲开空翻斩,然后反击。", "common_mistakes": null, - "name_zh": "双手里剑 + 后翻", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shadowfall", "name": "Shadowfall", + "name_zh": "秘传·巨型忍者落杀", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E5%B7%A8%E5%9E%8B%E5%BF%8D%E8%80%85%E8%90%BD%E6%9D%80", "move_type": "thrust", "description": "Unblockable thrust; if it hits, he jumps off and comes down with two huge slashes.", + "description_zh": "无法防御的突刺;如果命中,他会跳起再落下,接两记巨大的斩击。", "telegraph": "Charges up before the unblockable thrust.", + "telegraph_zh": "无法防御的突刺前会蓄力。", "counter": "Mikiri the thrust.", + "counter_zh": "识破这记突刺。", "common_mistakes": null, - "name_zh": "秘传·巨型忍者落杀", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E5%B7%A8%E5%9E%8B%E5%BF%8D%E8%80%85%E8%90%BD%E6%9D%80" + "common_mistakes_zh": null }, { "id": "shuriken-slash", "name": "Shuriken Slash", + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Throws a shuriken immediately followed by a running slash.", + "description_zh": "投掷一枚手里剑,紧接着冲刺斩。", "telegraph": null, + "telegraph_zh": null, "counter": "Block/deflect both. A well-timed dodge to the right is also possible.", + "counter_zh": "两下都防御或弹开。时机准确的话,也可以向右闪避。", "common_mistakes": null, - "name_zh": "手里剑 + 斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-sweep", "name": "Perilous Sweep", + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Unblockable sweep attack performed from the side.", + "description_zh": "从侧面发动的无法防御的横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it and stay close to bait his overhead slash, or jump back for a jump kick to the head.", + "counter_zh": "跳过去并留在他身边,引诱他出下劈;或者向后跳,踩头反击。", "common_mistakes": null, - "name_zh": "危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "large-overhead-slash", "name": "Large Overhead Slash", + "name_zh": "大幅度下劈", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "A very powerful overhead slash that adapts based on the player's response.", + "description_zh": "威力极大的下劈,会根据玩家的反应改变出招。", "telegraph": "Loads up for the overhead slash.", + "telegraph_zh": "蓄力准备下劈。", "counter": "Dodge at the last moment and counter, or step back out of range. Hits can land during the charge-up.", + "counter_zh": "在最后一刻闪避再反击,或者后退到范围外。他蓄力期间可以打中他。", "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack.", - "name_zh": "大幅度下劈", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "闪避太早会让他改成一记快速横斩。" }, { "id": "backflip", "name": "Backflip", + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "Leaps away; deals no vitality damage but significant posture damage.", + "description_zh": "向后跃开;不造成体力伤害,但会造成大量躯干伤害。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect to minimize posture damage.", + "counter_zh": "弹刀以尽量减少躯干伤害。", "common_mistakes": null, - "name_zh": "后空翻", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "firecrackers", "name": "Firecrackers", + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Throws blinding firecrackers followed by an explosion; can chain into Shuriken Throw, Shadowfall, or Shuriken Slash.", + "description_zh": "扔出致盲的爆竹并引发爆炸;之后可能接投掷手里剑、秘传·巨型忍者落杀或手里剑 + 斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge backwards to avoid.", + "counter_zh": "向后闪避躲开。", "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you.", - "name_zh": "爆竹", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "他扔出爆竹后闪避得太快,会被他接下来的斩击命中。" }, { "id": "running-slash", "name": "Running Slash", + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A single strong slash performed while approaching the player.", + "description_zh": "接近玩家时发动的一记强力斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Block/deflect. Dodging to the left is also possible.", + "counter_zh": "防御或弹刀。也可以向左闪避。", "common_mistakes": null, - "name_zh": "冲刺斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "five-slash-firecracker-combo", "name": "5-Slash Firecracker Combo", + "name_zh": "五连斩接爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Three consecutive sword slashes, followed by an elbow strike, followed by firecrackers that he slashes through.", + "description_zh": "连续三次挥刀,接一记肘击,然后扔出爆竹并挥刀穿过。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect/block the slashes, then dodge right when the firecrackers are thrown for a counter opportunity.", + "counter_zh": "弹开或防御前几刀,在他扔出爆竹时向右闪避,寻找反击机会。", "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival.", - "name_zh": "五连斩接爆竹", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "被这套连击逼进角落,那里几乎没有活路。" }, { "id": "double-slash-perilous-sweep", "name": "Double Slash into Perilous Sweep", + "name_zh": "二连斩接危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two consecutive slashes ending in an unblockable sweep; can chain into Large Overhead Slash.", + "description_zh": "连续两次斩击,最后接一记无法防御的横扫;之后可能接大幅度下劈。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it and stay close to bait his overhead slash.", + "counter_zh": "跳过去并留在他身边,引诱他出下劈。", "common_mistakes": null, - "name_zh": "二连斩接危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "stomp-deathblow", "name": "Stomp (Deathblow)", + "name_zh": "踩踏(忍杀)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Raises a foot then stomps down, pinning the player and stabbing through the chest for massive damage. Cannot be blocked and gives no Perilous indicator.", + "description_zh": "抬起一只脚然后踩下,把玩家踩住并刺穿胸口,造成大量伤害。无法防御,也没有“危”字提示。", "telegraph": "Raises his foot into the air before stomping down. There is no Perilous indicator.", + "telegraph_zh": "踩下前会把脚高高抬起。没有“危”字提示。", "counter": "Dodge backwards or safety roll to avoid it.", + "counter_zh": "向后闪避或受身翻滚躲开。", "common_mistakes": null, - "name_zh": "踩踏(忍杀)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "backflip-shuriken", "name": "Backflip + Shuriken", + "name_zh": "后空翻 + 手里剑", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Same as the Phase 1 backflip, with a shuriken thrown afterward.", + "description_zh": "和第一阶段的后空翻相同,之后再扔出一枚手里剑。", "telegraph": null, + "telegraph_zh": null, "counter": "Block/deflect both.", + "counter_zh": "两下都防御或弹开。", "common_mistakes": null, - "name_zh": "后空翻 + 手里剑", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "owl-teleport", "name": "Owl Teleport", + "name_zh": "枭之瞬移", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "teleport", "description": "Uses his summoned owl to teleport above the player and drop down with a slash.", + "description_zh": "借助召唤出的枭瞬移到玩家上方,落下时挥刀斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect the slash, or run/stay away from the owl to avoid it.", + "counter_zh": "弹开这一斩,或者跑开、远离那只枭来躲开。", "common_mistakes": null, - "name_zh": "枭之瞬移", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "fire-owl-shadowfall", "name": "Fire Owl + Shadowfall", + "name_zh": "火枭 + 巨型忍者落杀", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Summons his owl and throws an unblockable fire owl projectile, then follows up with Shadowfall.", + "description_zh": "召唤出枭并扔出一只无法防御的火枭,然后接秘传·巨型忍者落杀。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over the fire owl and Mikiri the thrust.", + "counter_zh": "跳过火枭,然后识破那记突刺。", "common_mistakes": null, - "name_zh": "火枭 + 巨型忍者落杀", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -825,186 +1088,245 @@ "name_zh": "蝴蝶夫人", "game": "sekiro", "location": "Hirata Estate - Hidden Temple", + "location_zh": "平田宅邸 - 佛殿", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Lady+Butterfly", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "kunai-throw", "name": "Kunai Throw", + "name_zh": "投掷苦无", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Projectile attack using kunai.", + "description_zh": "投掷苦无的远程攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be blocked or deflected.", + "counter_zh": "可以防御或弹刀。", "common_mistakes": null, - "name_zh": "投掷苦无", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-into-rafters", "name": "Jump into the Rafters", + "name_zh": "跃上房梁", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "Leaps into the air and disappears.", + "description_zh": "跃到空中后消失。", "telegraph": null, + "telegraph_zh": null, "counter": "Use the Shuriken Prosthetic immediately as she begins jumping to knock her out of the air and create an opening.", + "counter_zh": "她一开始起跳就立刻使用手里剑,把她打下来并制造破绽。", "common_mistakes": "Throwing the shuriken too late - she will likely deflect it.", - "name_zh": "跃上房梁", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "手里剑扔得太晚——她很可能会弹开。" }, { "id": "perilous-aerial-grab", "name": "Perilous Attack - Above (Aerial Grab)", + "name_zh": "危·空中投技", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Overhead perilous attack performed while airborne.", + "description_zh": "在空中发动的自上而下的危攻击。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Dodge or move out of the way. Alternatively, use the Shuriken Prosthetic to knock her down for a punish window.", + "counter_zh": "闪避或移动躲开。也可以用手里剑把她打下来,获得反击时机。", "common_mistakes": null, - "name_zh": "危·空中投技", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-sweep", "name": "Perilous Attack - Sweep", + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Low-sweeping perilous attack.", + "description_zh": "贴地横扫的危攻击。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Jump and kick her for considerable posture damage.", + "counter_zh": "跳起踩她,能造成可观的躯干伤害。", "common_mistakes": null, - "name_zh": "危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "ground-combos", "name": "Ground Combos", + "name_zh": "地面连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Standard melee attack sequences performed from ground level.", + "description_zh": "在地面上发动的常规近战连击。", "telegraph": null, + "telegraph_zh": null, "counter": "Stay close, deflect attacks, then counter-attack during her recovery windows.", + "counter_zh": "贴近她,弹开攻击,然后在她的收招空档反击。", "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks.", - "name_zh": "地面连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "攻击太多。弹刀要多于攻击,只在出现破绽后出手,比如她的危攻击之后。" } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "kunai-throw", "name": "Kunai Throw", + "name_zh": "投掷苦无", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Projectile attack using kunai.", + "description_zh": "投掷苦无的远程攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be blocked or deflected.", + "counter_zh": "可以防御或弹刀。", "common_mistakes": null, - "name_zh": "投掷苦无", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-into-rafters", "name": "Jump into the Rafters", + "name_zh": "跃上房梁", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "Leaps into the air and disappears.", + "description_zh": "跃到空中后消失。", "telegraph": null, + "telegraph_zh": null, "counter": "Use the Shuriken Prosthetic immediately as she begins jumping to knock her out of the air and create an opening.", + "counter_zh": "她一开始起跳就立刻使用手里剑,把她打下来并制造破绽。", "common_mistakes": "Throwing the shuriken too late - she will likely deflect it.", - "name_zh": "跃上房梁", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "手里剑扔得太晚——她很可能会弹开。" }, { "id": "perilous-aerial-grab", "name": "Perilous Attack - Above (Aerial Grab)", + "name_zh": "危·空中投技", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Overhead perilous attack performed while airborne.", + "description_zh": "在空中发动的自上而下的危攻击。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Dodge or move out of the way. Alternatively, use the Shuriken Prosthetic to knock her down for a punish window.", + "counter_zh": "闪避或移动躲开。也可以用手里剑把她打下来,获得反击时机。", "common_mistakes": null, - "name_zh": "危·空中投技", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "perilous-sweep", "name": "Perilous Attack - Sweep", + "name_zh": "危·下段横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Low-sweeping perilous attack.", + "description_zh": "贴地横扫的危攻击。", "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", "counter": "Jump and kick her for considerable posture damage.", + "counter_zh": "跳起踩她,能造成可观的躯干伤害。", "common_mistakes": null, - "name_zh": "危·下段横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "ground-combos", "name": "Ground Combos", + "name_zh": "地面连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Standard melee attack sequences performed from ground level.", + "description_zh": "在地面上发动的常规近战连击。", "telegraph": null, + "telegraph_zh": null, "counter": "Stay close, deflect attacks, then counter-attack during her recovery windows.", + "counter_zh": "贴近她,弹开攻击,然后在她的收招空档反击。", "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks.", - "name_zh": "地面连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "攻击太多。弹刀要多于攻击,只在出现破绽后出手,比如她的危攻击之后。" }, { "id": "illusions", "name": "Illusions", + "name_zh": "幻影", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "summon", "description": "Summons phantom enemies that attack with spears.", + "description_zh": "召唤手持长枪攻击的幻影敌人。", "telegraph": null, + "telegraph_zh": null, "counter": "Use a Snap Seed to dispel the phantoms, or kill them individually for Spirit Emblems. Alternatively, run around the arena perimeter to avoid their attacks.", + "counter_zh": "使用拍手种子驱散幻影,或者逐个击杀它们来获得纸人。也可以沿着场地边缘跑动来躲开它们的攻击。", "common_mistakes": null, - "name_zh": "幻影", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "phantom-kunai", "name": "Phantom Kunai", + "name_zh": "幻影苦无", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Upgraded kunai projectile attack with additional volleys.", + "description_zh": "强化版的苦无远程攻击,会多扔几轮。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect or block; dodging sideways offers the best protection.", + "counter_zh": "弹刀或防御;向侧面闪避最稳妥。", "common_mistakes": null, - "name_zh": "幻影苦无", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "homing-butterflies", "name": "Homing Butterflies", + "name_zh": "追踪幻蝶", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Homing projectiles created when illusions are dispelled.", + "description_zh": "驱散幻影时产生的追踪弹。", "telegraph": null, + "telegraph_zh": null, "counter": "Hide behind pillars or keep sprinting around the arena perimeter.", + "counter_zh": "躲到柱子后面,或者沿着场地边缘持续奔跑。", "common_mistakes": "Turning corners while the butterflies are tracking you can make them hit you.", - "name_zh": "追踪幻蝶", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "在幻蝶追踪你时转弯,可能会被它们打中。" }, { "id": "aerial-attacks-enhanced", "name": "Aerial Attacks (Enhanced)", + "name_zh": "空中攻击(强化)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Enhanced aerial maneuvers performed with increased frequency.", + "description_zh": "强化的空中动作,使用频率更高。", "telegraph": null, + "telegraph_zh": null, "counter": "Continue using the Shuriken Prosthetic to interrupt; maintain aggressive pressure to prevent aerial transitions.", + "counter_zh": "继续用手里剑打断;保持压制,不给她跃到空中的机会。", "common_mistakes": null, - "name_zh": "空中攻击(强化)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -1016,150 +1338,197 @@ "name_zh": "狮子猿", "game": "sekiro", "location": "Sunken Valley Passage", + "location_zh": "坠落谷 - 通道", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Guardian+Ape", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "rapid-slamming", "name": "Rapid Slamming", + "name_zh": "连续拍地", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Rapid slamming combinations performed at close range.", + "description_zh": "近距离的快速拍地连击。", "telegraph": null, + "telegraph_zh": null, "counter": "All slamming attacks can be blocked; attempt to deflect them.", + "counter_zh": "所有拍地攻击都可以防御;尽量弹刀。", "common_mistakes": null, - "name_zh": "连续拍地", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "hand-swipe-grab", "name": "Hand Swipe Grab", + "name_zh": "挥手抓取", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Unblockable grab attack where the ape swipes the floor from left to right at close range.", + "description_zh": "无法防御的抓取攻击:狮子猿在近距离从左到右扫过地面。", "telegraph": null, + "telegraph_zh": null, "counter": "Time a jump to dodge it.", + "counter_zh": "看准时机跳起躲开。", "common_mistakes": null, - "name_zh": "挥手抓取", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "body-slam-grab", "name": "Body Slam Grab", + "name_zh": "扑身抓取", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "The ape jumps and attempts to grab the player.", + "description_zh": "狮子猿跳起,试图抓住玩家。", "telegraph": null, + "telegraph_zh": null, "counter": "Time a dodge to avoid it; a double dodge (tap the button twice) is safer.", + "counter_zh": "看准时机闪避;连续闪避两次(快速按两下)更安全。", "common_mistakes": null, - "name_zh": "扑身抓取", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "poisonous-fart", "name": "Poisonous Fart", + "name_zh": "毒屁", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "status", "description": "Status attack inflicting heavy poison buildup when the ape's back is turned; always followed by running away and jumping high.", + "description_zh": "狮子猿背对玩家时放出的状态攻击,会大量累积中毒;之后一定会跑开并高高跃起。", "telegraph": null, + "telegraph_zh": null, "counter": "Quickly dodge away from the cloud to avoid the poison.", + "counter_zh": "迅速闪避离开毒雾,避免中毒。", "common_mistakes": null, - "name_zh": "毒屁", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "poisonous-poo-throw", "name": "Poisonous Poo Throw", + "name_zh": "投掷毒粪", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "High-damage instant poison attack.", + "description_zh": "高伤害、立即中毒的攻击。", "telegraph": "Jumps high before throwing.", + "telegraph_zh": "投掷前会高高跃起。", "counter": "Run underneath as it jumps and retaliate; its recovery from this attack is lengthy.", + "counter_zh": "在它跃起时跑到它身下再反击;这招的收招硬直很长。", "common_mistakes": null, - "name_zh": "投掷毒粪", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "roar-jump-back", "name": "Roar / Jump Back", + "name_zh": "咆哮 / 后跳", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "movement", "description": "The ape jumps back and unleashes a roar.", + "description_zh": "狮子猿向后跳开并发出咆哮。", "telegraph": null, + "telegraph_zh": null, "counter": "A grapple prompt appears, letting Sekiro close the gap and causing a brief stagger.", + "counter_zh": "会出现钩绳提示,可以借此拉近距离,并让它短暂硬直。", "common_mistakes": null, - "name_zh": "咆哮 / 后跳", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "rolling-body-slam", "name": "Body Slam (Rolling)", + "name_zh": "翻滚压击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "The ape jumps onto its back and thrashes the ground.", + "description_zh": "狮子猿仰面倒地,在地上翻滚拍打。", "telegraph": null, + "telegraph_zh": null, "counter": "Stay away from its arms and hit its head.", + "counter_zh": "远离它的手臂,攻击它的头部。", "common_mistakes": null, - "name_zh": "翻滚压击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "wide-sword-swipes", "name": "Sword Swipes (Wide Area)", + "name_zh": "大范围挥刀", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Sword attacks that cover a wide area, after the ape decapitates itself and resurrects wielding a sword through its neck.", + "description_zh": "狮子猿被斩首后复活,把刀插在脖子里挥舞,攻击范围很大。", "telegraph": null, + "telegraph_zh": null, "counter": "Strike a few times after its combos, then back away.", + "counter_zh": "在它的连击后攻击几下,然后退开。", "common_mistakes": "Staying on top of it and attacking for too long.", - "name_zh": "大范围挥刀", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "一直贴着它攻击太久。" }, { "id": "diving-lunge", "name": "Diving Lunge", + "name_zh": "飞扑", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Lunges forward with a swipe while falling to the ground.", + "description_zh": "向前扑出并挥爪,同时扑倒在地。", "telegraph": "Clear wind-up before it dives.", + "telegraph_zh": "扑出前有明显的蓄力动作。", "counter": "Jump over it as it dives and strike it in the air, following with a full combo.", + "counter_zh": "在它扑出时跳过去并在空中攻击,然后接一整套连击。", "common_mistakes": null, - "name_zh": "飞扑", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "overhead-slam", "name": "Overhead Slam", + "name_zh": "高举砸地", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "Large overhead attack that can be deflected.", + "description_zh": "大幅度的高举砸地,可以弹刀。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflecting it stuns the boss and exposes its neck. Using the Loaded Spear (activate it twice) briefly pulls out the centipede for massive posture and health damage.", + "counter_zh": "弹开这招会让它硬直并露出脖子。使用机关长枪(发动两次)可以短暂拔出蜈蚣,造成大量躯干和体力伤害。", "common_mistakes": null, - "name_zh": "高举砸地", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "blood-scream", "name": "Blood Scream", + "name_zh": "血之咆哮", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "terror", "description": "Battle roar that unleashes shockwaves filling the Terror meter, which can inflict Terror status and instantly kill Sekiro.", + "description_zh": "战吼,释放的冲击波会累积怖,怖满后会立即杀死狼。", "telegraph": "Puts its head back on its neck before screaming.", + "telegraph_zh": "咆哮前会把头放回脖子上。", "counter": "Any Loaded Umbrella-type prosthetic completely negates the scream; the Phoenix's Lilac Umbrella is recommended for NG+.", + "counter_zh": "任何机关伞类义手忍具都能完全抵挡这声咆哮;二周目以后推荐使用凤凰紫伞。", "common_mistakes": null, - "name_zh": "血之咆哮", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -1171,96 +1540,126 @@ "name_zh": "破戒僧(幻影)", "game": "sekiro", "location": "Ashina Depths - Mibu Village", + "location_zh": "苇名之底 - 水生村", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Corrupted+Monk", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "sweep-attack", "name": "Sweep Attack", + "name_zh": "横扫攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Perilous sweep attack variant, often followed by attack opportunities.", + "description_zh": "横扫类的危攻击,之后通常有攻击机会。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it.", + "counter_zh": "跳过去。", "common_mistakes": null, - "name_zh": "横扫攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "thrust-attack", "name": "Thrust Attack", + "name_zh": "突刺攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Perilous thrust attack variant.", + "description_zh": "突刺类的危攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Mikiri Counter.", + "counter_zh": "识破。", "common_mistakes": null, - "name_zh": "突刺攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-attack-sweep", "name": "Jump Attack (Sweep Variant)", + "name_zh": "跳跃攻击(横扫)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Jumping attack that sweeps during the jump.", + "description_zh": "跳跃攻击,在跳跃过程中横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep and strike as she lands.", + "counter_zh": "向侧面闪避,在她落地时出手攻击。", "common_mistakes": null, - "name_zh": "跳跃攻击(横扫)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-attack-overhead", "name": "Jump Attack (Overhead Variant)", + "name_zh": "跳跃攻击(下劈)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "Jumping attack that ends in an overhead slash.", + "description_zh": "跳跃攻击,最后以一记下劈收尾。", "telegraph": null, + "telegraph_zh": null, "counter": "Run below and attack her exposed back.", + "counter_zh": "跑到她身下,攻击她露出的背后。", "common_mistakes": null, - "name_zh": "跳跃攻击(下劈)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "upwards-slash", "name": "Upwards Slash", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "An upward slash that goes through Sekiro's normal defenses and cannot be blocked.", + "description_zh": "一记上挑斩,会穿透狼的常规防御,无法防御。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be deflected, though the timing is very precise.", + "counter_zh": "可以弹刀,但时机要求非常精确。", "common_mistakes": null, - "name_zh": "上挑斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "whirlwind-attack", "name": "Whirlwind Attack", + "name_zh": "旋风攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Continuous spinning attack with the Naginata that can quickly exhaust Sekiro's posture gauge. Tends to be used when at a distance from the player, more often if the player uses a consumable.", + "description_zh": "挥舞薙刀持续旋转攻击,能迅速打满狼的躯干。通常在与玩家有一定距离时使用,玩家使用道具时更频繁。", "telegraph": null, + "telegraph_zh": null, "counter": "Run away when she starts spinning.", + "counter_zh": "她开始旋转时就跑开。", "common_mistakes": null, - "name_zh": "旋风攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "mid-range-combo", "name": "Mid-Range Combo", + "name_zh": "中距离连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "One jumping air hit followed by two ground hits at mid-range.", + "description_zh": "先跳起在空中攻击一次,接着在中距离落地连续攻击两次。", "telegraph": null, + "telegraph_zh": null, "counter": "Let the landing cancel the air-attack lag, then jump away to mid-range.", + "counter_zh": "利用落地抵消空中攻击的硬直,然后跳开拉到中距离。", "common_mistakes": null, - "name_zh": "中距离连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -1272,216 +1671,284 @@ "name_zh": "宫内破戒僧", "game": "sekiro", "location": "Fountainhead Palace", + "location_zh": "源之宫", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Corrupted+Monk", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "sweep-attack", "name": "Sweep Attack", + "name_zh": "横扫攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Perilous sweep attack variant, used more often here than in the Mibu Village fight. More telegraphed and predictable in Phase 3.", + "description_zh": "横扫类的危攻击,这里比水生村那一战用得更频繁。第三阶段的预兆更明显,也更好预判。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it.", + "counter_zh": "跳过去。", "common_mistakes": null, - "name_zh": "横扫攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "thrust-attack", "name": "Thrust Attack", + "name_zh": "突刺攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Perilous thrust attack variant, used more often here than in the Mibu Village fight.", + "description_zh": "突刺类的危攻击,这里比水生村那一战用得更频繁。", "telegraph": null, + "telegraph_zh": null, "counter": "Mikiri Counter.", + "counter_zh": "识破。", "common_mistakes": null, - "name_zh": "突刺攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-attack-sweep", "name": "Jump Attack (Sweep Variant)", + "name_zh": "跳跃攻击(横扫)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Jumping attack that sweeps during the jump.", + "description_zh": "跳跃攻击,在跳跃过程中横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep and strike as she lands.", + "counter_zh": "向侧面闪避,在她落地时出手攻击。", "common_mistakes": null, - "name_zh": "跳跃攻击(横扫)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-attack-overhead", "name": "Jump Attack (Overhead Variant)", + "name_zh": "跳跃攻击(下劈)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "Jumping attack that ends in an overhead slash. In Phase 3 it tends to be used as a finishing move.", + "description_zh": "跳跃攻击,最后以一记下劈收尾。第三阶段她常用这招收尾。", "telegraph": null, + "telegraph_zh": null, "counter": "Run below and attack her exposed back.", + "counter_zh": "跑到她身下,攻击她露出的背后。", "common_mistakes": null, - "name_zh": "跳跃攻击(下劈)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "upwards-slash", "name": "Upwards Slash", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "An upward slash that goes through Sekiro's normal defenses and cannot be blocked.", + "description_zh": "一记上挑斩,会穿透狼的常规防御,无法防御。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be deflected, though the timing is very precise.", + "counter_zh": "可以弹刀,但时机要求非常精确。", "common_mistakes": null, - "name_zh": "上挑斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "mid-range-combo", "name": "Mid-Range Combo", + "name_zh": "中距离连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "One jumping air hit followed by two ground hits at mid-range.", + "description_zh": "先跳起在空中攻击一次,接着在中距离落地连续攻击两次。", "telegraph": null, + "telegraph_zh": null, "counter": "Let the landing cancel the air-attack lag, then jump away to mid-range.", + "counter_zh": "利用落地抵消空中攻击的硬直,然后跳开拉到中距离。", "common_mistakes": null, - "name_zh": "中距离连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "illusion-summoning", "name": "Illusion Summoning", + "name_zh": "召唤幻影", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "summon", "description": "She disappears and the area fills with mist, then summons three shadow illusions of herself that each attack the player three times. The shadows disappear between attacks, making lock-on difficult. Standing near a wall before the shadows spawn limits them to one direction, repeating the same three moves.", + "description_zh": "她消失后场地充满雾气,接着召唤三个自己的影子幻影,每个都会攻击玩家三次。影子在攻击间隙会消失,很难锁定。影子出现前站在墙边,可以让它们只从一个方向出现,重复同样的三招。", "telegraph": null, + "telegraph_zh": null, "counter": "The shadows don't need to be defeated - she eventually ends the illusion and returns on her own. A Bite Down pill instantly dispels them too.", + "counter_zh": "不需要击败影子——她最终会自己结束幻影并回来。咬破药丸也能立即驱散它们。", "common_mistakes": null, - "name_zh": "召唤幻影", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 3, "name": "Phase 3", + "name_zh": "第三阶段", "moves": [ { "id": "sweep-attack", "name": "Sweep Attack", + "name_zh": "横扫攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Perilous sweep attack variant, used more often here than in the Mibu Village fight. More telegraphed and predictable in Phase 3.", + "description_zh": "横扫类的危攻击,这里比水生村那一战用得更频繁。第三阶段的预兆更明显,也更好预判。", "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it.", + "counter_zh": "跳过去。", "common_mistakes": null, - "name_zh": "横扫攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "thrust-attack", "name": "Thrust Attack", + "name_zh": "突刺攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "Perilous thrust attack variant, used more often here than in the Mibu Village fight.", + "description_zh": "突刺类的危攻击,这里比水生村那一战用得更频繁。", "telegraph": null, + "telegraph_zh": null, "counter": "Mikiri Counter.", + "counter_zh": "识破。", "common_mistakes": null, - "name_zh": "突刺攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-attack-sweep", "name": "Jump Attack (Sweep Variant)", + "name_zh": "跳跃攻击(横扫)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Jumping attack that sweeps during the jump.", + "description_zh": "跳跃攻击,在跳跃过程中横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep and strike as she lands.", + "counter_zh": "向侧面闪避,在她落地时出手攻击。", "common_mistakes": null, - "name_zh": "跳跃攻击(横扫)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "jump-attack-overhead", "name": "Jump Attack (Overhead Variant)", + "name_zh": "跳跃攻击(下劈)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "Jumping attack that ends in an overhead slash. In Phase 3 it tends to be used as a finishing move.", + "description_zh": "跳跃攻击,最后以一记下劈收尾。第三阶段她常用这招收尾。", "telegraph": null, + "telegraph_zh": null, "counter": "Run below and attack her exposed back.", + "counter_zh": "跑到她身下,攻击她露出的背后。", "common_mistakes": null, - "name_zh": "跳跃攻击(下劈)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "upwards-slash", "name": "Upwards Slash", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "An upward slash that goes through Sekiro's normal defenses and cannot be blocked.", + "description_zh": "一记上挑斩,会穿透狼的常规防御,无法防御。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be deflected, though the timing is very precise.", + "counter_zh": "可以弹刀,但时机要求非常精确。", "common_mistakes": null, - "name_zh": "上挑斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "mid-range-combo", "name": "Mid-Range Combo", + "name_zh": "中距离连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "One jumping air hit followed by two ground hits at mid-range.", + "description_zh": "先跳起在空中攻击一次,接着在中距离落地连续攻击两次。", "telegraph": null, + "telegraph_zh": null, "counter": "Let the landing cancel the air-attack lag, then jump away to mid-range.", + "counter_zh": "利用落地抵消空中攻击的硬直,然后跳开拉到中距离。", "common_mistakes": null, - "name_zh": "中距离连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "whirlwind-attack", "name": "Whirlwind Attack", + "name_zh": "旋风攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Continuous spinning attack with the Naginata, regained in this phase after being absent since Phase 1. Very susceptible to the Shinobi Firecracker, especially during the windup.", + "description_zh": "挥舞薙刀持续旋转攻击,第一阶段之后消失,在这个阶段重新出现。非常怕爆竹,尤其是在起手时。", "telegraph": null, + "telegraph_zh": null, "counter": "Run away when she starts spinning, or interrupt the windup with a Shinobi Firecracker.", + "counter_zh": "她开始旋转时就跑开,或者在她起手时用爆竹打断。", "common_mistakes": null, - "name_zh": "旋风攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "centipede-toxic-goo", "name": "Centipede Toxic Goo", + "name_zh": "百足毒液", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Perilous attack where the demon centipede erupting from her neck spews toxic goo in front of her; can inflict Terror.", + "description_zh": "危攻击:从她脖子里钻出的蜈蚣向前方喷出毒液;可能造成怖。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep or jump-kick when she uses this and retaliate - the attack has lengthy ending lag.", + "counter_zh": "她使用这招时向侧面闪避或跳起踩她,然后反击——这招收招硬直很长。", "common_mistakes": null, - "name_zh": "百足毒液", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "centipede-toxic-cloud", "name": "Centipede Toxic Cloud", + "name_zh": "百足毒雾", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "status", "description": "A non-perilous variant of the centipede's toxin attack.", + "description_zh": "蜈蚣毒液攻击的非危攻击版本。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "百足毒雾", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -1493,210 +1960,277 @@ "name_zh": "巨型忍者 枭", "game": "sekiro", "location": "Ashina Castle", + "location_zh": "苇名城", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Great+Shinobi+Owl", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "shinobi-charm", "name": "Shinobi Charm", + "name_zh": "忍符", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "status", "description": "Tosses a charm that releases a green mist, preventing healing item usage for 30 seconds.", + "description_zh": "扔出一张会释放绿色烟雾的符咒,30 秒内无法使用回复道具。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep and then strike, taking care not to get debuffed.", + "counter_zh": "向侧面闪避后再攻击,注意不要被施加负面效果。", "common_mistakes": "Walking into the mist, which locks you out of healing items.", - "name_zh": "忍符", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "走进烟雾里,导致无法使用回复道具。" }, { "id": "backflip", "name": "Backflip", + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Leaps backward while throwing a shuriken.", + "description_zh": "向后跃开,同时投掷一枚手里剑。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect or dodge the projectile.", + "counter_zh": "弹开或闪避手里剑。", "common_mistakes": null, - "name_zh": "后空翻", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "running-slash", "name": "Running Slash", + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A single powerful slash performed while charging toward the player.", + "description_zh": "冲向玩家时发动的一记强力斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect or dodge depending on posture status.", + "counter_zh": "根据自己的躯干状况选择弹刀或闪避。", "common_mistakes": null, - "name_zh": "冲刺斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "four-slash-combo", "name": "4-Slash Combo", + "name_zh": "四连斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Four consecutive sword strikes, often followed by Shinobi Charm.", + "description_zh": "连续挥刀四次,之后常接忍符。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect or retreat.", + "counter_zh": "弹刀或后撤。", "common_mistakes": null, - "name_zh": "四连斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shuriken-throw-flip", "name": "Shuriken Throw & Flip", + "name_zh": "投掷手里剑 + 翻身", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two shuriken throws followed by an airborne downward strike.", + "description_zh": "投掷两枚手里剑,然后从空中向下攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge diagonally forward through the flip strike to open up an opportunity to attack 3-4 times, or land two Mortal Draw hits.", + "counter_zh": "向斜前方闪避穿过空翻斩,就能获得攻击 3–4 次的机会,或者打出两次秘传·不死斩。", "common_mistakes": null, - "name_zh": "投掷手里剑 + 翻身", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shuriken-slash", "name": "Shuriken Slash", + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A quick shuriken throw immediately followed by a running slash.", + "description_zh": "快速投掷一枚手里剑,紧接着冲刺斩。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep the projectile, then deflect or retreat.", + "counter_zh": "向侧面躲开手里剑,然后弹刀或后撤。", "common_mistakes": null, - "name_zh": "手里剑 + 斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "firecracker", "name": "Firecracker", + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Black gunpowder explosion attack dealing significant damage.", + "description_zh": "黑色火药爆炸攻击,伤害很高。", "telegraph": "Black smoke.", + "telegraph_zh": "黑烟。", "counter": "Run away when you see black smoke, or dodge in aggressively.", + "counter_zh": "看到黑烟就跑开,或者主动闪避贴近。", "common_mistakes": null, - "name_zh": "爆竹", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "stomp-grab", "name": "Stomp (Grab)", + "name_zh": "踩踏(投技)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Raises a foot then stomps down, pinning and stabbing the player. Deals massive damage, cannot be blocked, and gives no Perilous indicator.", + "description_zh": "抬起一只脚然后踩下,把玩家踩住并刺下。伤害极高,无法防御,也没有“危”字提示。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "踩踏(投技)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "shinobi-charm", "name": "Shinobi Charm", + "name_zh": "忍符", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "status", "description": "Tosses a charm that releases a green mist, preventing healing item usage for 30 seconds.", + "description_zh": "扔出一张会释放绿色烟雾的符咒,30 秒内无法使用回复道具。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep and then strike, taking care not to get debuffed.", + "counter_zh": "向侧面闪避后再攻击,注意不要被施加负面效果。", "common_mistakes": "Walking into the mist, which locks you out of healing items.", - "name_zh": "忍符", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "走进烟雾里,导致无法使用回复道具。" }, { "id": "backflip-poison", "name": "Backflip (Poison)", + "name_zh": "后空翻(毒)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Same motion as the Phase 1 backflip, but throws poison instead of a shuriken.", + "description_zh": "和第一阶段的后空翻动作相同,但扔出的是毒而不是手里剑。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge and maintain distance from the poisoned area.", + "counter_zh": "闪避并远离中毒区域。", "common_mistakes": null, - "name_zh": "后空翻(毒)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "running-slash", "name": "Running Slash", + "name_zh": "冲刺斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A single powerful slash performed while charging toward the player.", + "description_zh": "冲向玩家时发动的一记强力斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect or dodge depending on posture status.", + "counter_zh": "根据自己的躯干状况选择弹刀或闪避。", "common_mistakes": null, - "name_zh": "冲刺斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "four-slash-combo", "name": "4-Slash Combo", + "name_zh": "四连斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Four consecutive sword strikes, often followed by Shinobi Charm.", + "description_zh": "连续挥刀四次,之后常接忍符。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect or retreat.", + "counter_zh": "弹刀或后撤。", "common_mistakes": null, - "name_zh": "四连斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shuriken-throw-flip", "name": "Shuriken Throw & Flip", + "name_zh": "投掷手里剑 + 翻身", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "Two shuriken throws followed by an airborne downward strike.", + "description_zh": "投掷两枚手里剑,然后从空中向下攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge diagonally forward through the flip strike to open up an opportunity to attack 3-4 times, or land two Mortal Draw hits.", + "counter_zh": "向斜前方闪避穿过空翻斩,就能获得攻击 3–4 次的机会,或者打出两次秘传·不死斩。", "common_mistakes": null, - "name_zh": "投掷手里剑 + 翻身", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "shuriken-slash", "name": "Shuriken Slash", + "name_zh": "手里剑 + 斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A quick shuriken throw immediately followed by a running slash.", + "description_zh": "快速投掷一枚手里剑,紧接着冲刺斩。", "telegraph": null, + "telegraph_zh": null, "counter": "Sidestep the projectile, then deflect or retreat.", + "counter_zh": "向侧面躲开手里剑,然后弹刀或后撤。", "common_mistakes": null, - "name_zh": "手里剑 + 斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "firecracker", "name": "Firecracker", + "name_zh": "爆竹", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Black gunpowder explosion attack dealing significant damage.", + "description_zh": "黑色火药爆炸攻击,伤害很高。", "telegraph": "Black smoke.", + "telegraph_zh": "黑烟。", "counter": "Run away when you see black smoke, or dodge in aggressively.", + "counter_zh": "看到黑烟就跑开,或者主动闪避贴近。", "common_mistakes": null, - "name_zh": "爆竹", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "stomp-grab", "name": "Stomp (Grab)", + "name_zh": "踩踏(投技)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Raises a foot then stomps down, pinning and stabbing the player. Deals massive damage, cannot be blocked, and gives no Perilous indicator.", + "description_zh": "抬起一只脚然后踩下,把玩家踩住并刺下。伤害极高,无法防御,也没有“危”字提示。", "telegraph": null, + "telegraph_zh": null, "counter": null, + "counter_zh": null, "common_mistakes": null, - "name_zh": "踩踏(投技)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } @@ -1708,528 +2242,700 @@ "name_zh": "剑圣 苇名一心", "game": "sekiro", "location": "Ashina Reservoir - Secret Passage", + "location_zh": "苇名城 贮水城区 - 秘密通道", "source_name": "Fextralife Sekiro Wiki", "source_url": "https://sekiroshadowsdietwice.wiki.fextralife.com/Isshin,+the+Sword+Saint", "phases": [ { "phase_number": 1, "name": "Phase 1", + "name_zh": "第一阶段", "moves": [ { "id": "dragon-flash", "name": "Dragon Flash", + "name_zh": "秘传·龙闪", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA", "move_type": "ranged", "description": "Strikes downward launching a force wave, followed by a second wave after a short delay.", + "description_zh": "向下斩击放出一道剑气,短暂延迟后再放出第二道。", "telegraph": "Sheathes his sword and readies a stance; a glint shows on the hilt.", + "telegraph_zh": "收刀入鞘摆出架势,刀柄上闪过一道光。", "counter": "Deflect the first wave, then dodge the second.", + "counter_zh": "弹开第一道剑气,然后闪避第二道。", "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", - "name_zh": "秘传·龙闪", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA" + "common_mistakes_zh": "防御剑气而不是弹开——防御仍然会受到伤害。" }, { "id": "ashina-cross", "name": "Ashina Cross", + "name_zh": "绝技·苇名十字斩", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9", "move_type": "combo", "description": "Steps forward with two rapid slashes.", + "description_zh": "向前踏步,快速斩击两次。", "telegraph": "Sheathes his sword; after a short delay there is a flash of light at his hand as he steps forward.", + "telegraph_zh": "收刀入鞘;短暂延迟后,他向前踏步时手上会闪过一道光。", "counter": "Press deflect twice without pausing, or run far away to avoid it entirely.", + "counter_zh": "连续按两次弹刀,中间不要停顿;或者跑远完全躲开。", "common_mistakes": null, - "name_zh": "绝技·苇名十字斩", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9" + "common_mistakes_zh": null }, { "id": "ashina-cross-cancel", "name": "Ashina Cross Cancel", + "name_zh": "苇名十字斩(取消)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "If the player is close when he readies Ashina Cross, he cancels it into a quick sword smack followed by a perilous sweep. He also does this after the player repeatedly deflects his attacks.", + "description_zh": "如果他准备苇名十字斩时玩家离得很近,他会中断改为快速一击,接一记危·下段横扫。玩家连续弹开他的攻击后,他也会这样做。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect the initial hit, then jump and stomp.", + "counter_zh": "弹开第一击,然后跳起踩他。", "common_mistakes": null, - "name_zh": "苇名十字斩(取消)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "ichimonji-double", "name": "Ichimonji Double", + "name_zh": "一字斩·二连", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E", "move_type": "overhead", "description": "Performs a double overhead strike.", + "description_zh": "连续两次下劈。", "telegraph": "Raises his blade into a ready stance.", + "telegraph_zh": "举刀摆出架势。", "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", + "counter_zh": "闪避或防御;也可以向侧面躲开。弹刀会让他硬直。", "common_mistakes": null, - "name_zh": "一字斩·二连", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E" + "common_mistakes_zh": null }, { "id": "distance-slash", "name": "Distance Slash", + "name_zh": "远距离斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "If the player is far away or using an item, he quickly dashes in and slashes.", + "description_zh": "如果玩家离得很远或正在使用道具,他会迅速冲过来斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Maintain enough distance to react in time.", + "counter_zh": "保持足够的距离,以便及时反应。", "common_mistakes": null, - "name_zh": "远距离斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "charged-horizontal-force-wave", "name": "Charged Attack, Horizontal Force Wave", + "name_zh": "蓄力横向剑气", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Swings in a spinning motion, causing a large circular force wave.", + "description_zh": "旋转挥刀,放出一道巨大的环形剑气。", "telegraph": "Sheathes his sword and charges the attack.", + "telegraph_zh": "收刀入鞘,蓄力。", "counter": "Wait, then deflect as soon as he begins moving his arm, or sprint and jump toward him, then jump again onto his head.", + "counter_zh": "等待,在他开始挥动手臂时立刻弹刀;或者冲刺跳向他,再踩他的头跳起。", "common_mistakes": null, - "name_zh": "蓄力横向剑气", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "three-to-four-hit-combo", "name": "Three-to-Four Hit Combo", + "name_zh": "三至四连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A series of main slashes with delays between hits.", + "description_zh": "一连串主要斩击,每击之间有停顿。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect each hit; once he finishes slashing he's open for a quick two hits.", + "counter_zh": "逐一弹开;他砍完后会露出破绽,可以快速攻击两下。", "common_mistakes": null, - "name_zh": "三至四连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "thrust", "name": "Thrust", + "name_zh": "突刺", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "A telegraphed thrust attack.", + "description_zh": "有明显预兆的突刺攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Mikiri Counter - easy to react to once telegraphed.", + "counter_zh": "识破——看到预兆后很容易反应。", "common_mistakes": null, - "name_zh": "突刺", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "long-range-wind-attack", "name": "Long Range Wind Attack", + "name_zh": "远距离风压攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "A long-range wind-based attack.", + "description_zh": "远距离的风压攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge by running sideways to gain a couple of free hits.", + "counter_zh": "向侧面跑动躲开,可以白打几下。", "common_mistakes": null, - "name_zh": "远距离风压攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "close-range-full-screen-sweep", "name": "Close Range Full Screen Sweep", + "name_zh": "近距离全屏横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "A close-range sweep that covers the full screen.", + "description_zh": "覆盖全屏的近距离横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Sprint backwards before it goes off.", + "counter_zh": "在它发动前向后冲刺。", "common_mistakes": null, - "name_zh": "近距离全屏横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ { "id": "dragon-flash", "name": "Dragon Flash", + "name_zh": "秘传·龙闪", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA", "move_type": "ranged", "description": "Strikes downward launching a force wave, followed by a second wave after a short delay.", + "description_zh": "向下斩击放出一道剑气,短暂延迟后再放出第二道。", "telegraph": "Sheathes his sword and readies a stance; a glint shows on the hilt.", + "telegraph_zh": "收刀入鞘摆出架势,刀柄上闪过一道光。", "counter": "Deflect the first wave, then dodge the second.", + "counter_zh": "弹开第一道剑气,然后闪避第二道。", "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", - "name_zh": "秘传·龙闪", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA" + "common_mistakes_zh": "防御剑气而不是弹开——防御仍然会受到伤害。" }, { "id": "ashina-cross", "name": "Ashina Cross", + "name_zh": "绝技·苇名十字斩", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9", "move_type": "combo", "description": "Steps forward with two rapid slashes.", + "description_zh": "向前踏步,快速斩击两次。", "telegraph": "Sheathes his sword; after a short delay there is a flash of light at his hand as he steps forward.", + "telegraph_zh": "收刀入鞘;短暂延迟后,他向前踏步时手上会闪过一道光。", "counter": "Press deflect twice without pausing, or run far away to avoid it entirely.", + "counter_zh": "连续按两次弹刀,中间不要停顿;或者跑远完全躲开。", "common_mistakes": null, - "name_zh": "绝技·苇名十字斩", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9" + "common_mistakes_zh": null }, { "id": "ashina-cross-cancel", "name": "Ashina Cross Cancel", + "name_zh": "苇名十字斩(取消)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "If the player is close when he readies Ashina Cross, he cancels it into a quick sword smack followed by a perilous sweep. He also does this after the player repeatedly deflects his attacks.", + "description_zh": "如果他准备苇名十字斩时玩家离得很近,他会中断改为快速一击,接一记危·下段横扫。玩家连续弹开他的攻击后,他也会这样做。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect the initial hit, then jump and stomp.", + "counter_zh": "弹开第一击,然后跳起踩他。", "common_mistakes": null, - "name_zh": "苇名十字斩(取消)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "ichimonji-double", "name": "Ichimonji Double", + "name_zh": "一字斩·二连", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E", "move_type": "overhead", "description": "Performs a double overhead strike.", + "description_zh": "连续两次下劈。", "telegraph": "Raises his blade into a ready stance.", + "telegraph_zh": "举刀摆出架势。", "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", + "counter_zh": "闪避或防御;也可以向侧面躲开。弹刀会让他硬直。", "common_mistakes": null, - "name_zh": "一字斩·二连", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E" + "common_mistakes_zh": null }, { "id": "distance-slash", "name": "Distance Slash", + "name_zh": "远距离斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "If the player is far away or using an item, he quickly dashes in and slashes.", + "description_zh": "如果玩家离得很远或正在使用道具,他会迅速冲过来斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Maintain enough distance to react in time.", + "counter_zh": "保持足够的距离,以便及时反应。", "common_mistakes": null, - "name_zh": "远距离斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "charged-horizontal-force-wave", "name": "Charged Attack, Horizontal Force Wave", + "name_zh": "蓄力横向剑气", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Swings in a spinning motion, causing a large circular force wave.", + "description_zh": "旋转挥刀,放出一道巨大的环形剑气。", "telegraph": "Sheathes his sword and charges the attack.", + "telegraph_zh": "收刀入鞘,蓄力。", "counter": "Wait, then deflect as soon as he begins moving his arm, or sprint and jump toward him, then jump again onto his head.", + "counter_zh": "等待,在他开始挥动手臂时立刻弹刀;或者冲刺跳向他,再踩他的头跳起。", "common_mistakes": null, - "name_zh": "蓄力横向剑气", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "three-to-four-hit-combo", "name": "Three-to-Four Hit Combo", + "name_zh": "三至四连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A series of main slashes with delays between hits.", + "description_zh": "一连串主要斩击,每击之间有停顿。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect each hit; once he finishes slashing he's open for a quick two hits.", + "counter_zh": "逐一弹开;他砍完后会露出破绽,可以快速攻击两下。", "common_mistakes": null, - "name_zh": "三至四连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "thrust", "name": "Thrust", + "name_zh": "突刺", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "A telegraphed thrust attack.", + "description_zh": "有明显预兆的突刺攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Mikiri Counter - easy to react to once telegraphed.", + "counter_zh": "识破——看到预兆后很容易反应。", "common_mistakes": null, - "name_zh": "突刺", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "long-range-wind-attack", "name": "Long Range Wind Attack", + "name_zh": "远距离风压攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "A long-range wind-based attack.", + "description_zh": "远距离的风压攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge by running sideways to gain a couple of free hits.", + "counter_zh": "向侧面跑动躲开,可以白打几下。", "common_mistakes": null, - "name_zh": "远距离风压攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "close-range-full-screen-sweep", "name": "Close Range Full Screen Sweep", + "name_zh": "近距离全屏横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "A close-range sweep that covers the full screen.", + "description_zh": "覆盖全屏的近距离横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Sprint backwards before it goes off.", + "counter_zh": "在它发动前向后冲刺。", "common_mistakes": null, - "name_zh": "近距离全屏横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "charged-double-projected-force", "name": "Charge Attack, Double Projected Force", + "name_zh": "蓄力双重剑气", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Makes two slashes that each launch a force disc. Deflecting the discs does not affect his posture.", + "description_zh": "斩击两次,每次放出一道圆盘状剑气。弹开剑气不会影响他的躯干。", "telegraph": "Sheathes his sword and charges the attack.", + "telegraph_zh": "收刀入鞘,蓄力。", "counter": "The discs deal damage through block if not deflected properly, but the attack is easy to dodge.", + "counter_zh": "如果没有正确弹开,剑气会穿透防御造成伤害,不过这招很容易闪避。", "common_mistakes": null, - "name_zh": "蓄力双重剑气", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "leaping-slam", "name": "Leaping Slam", + "name_zh": "跳跃砸击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "When far away or the player is using an item, he leaps and slams with his spear, followed by a sword strike or gunshots.", + "description_zh": "玩家离得很远或正在使用道具时,他会跃起用长枪砸下,之后接一记挥刀或枪击。", "telegraph": "Leaps into the air before slamming down with his spear.", + "telegraph_zh": "用长枪砸下前会先跃到空中。", "counter": "Deflect the slam, then counter-attack.", + "counter_zh": "弹开这一砸,然后反击。", "common_mistakes": null, - "name_zh": "跳跃砸击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "retreating-sweep", "name": "Retreating Sweep", + "name_zh": "后撤横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Leaps backward into the air and sweeps with his spear, often following several blocked player attacks. Comes out very quickly.", + "description_zh": "向后跃到空中并用长枪横扫,常在玩家连续几次攻击被他防住后出现。出招非常快。", "telegraph": "Leaps backward into the air.", + "telegraph_zh": "向后跃到空中。", "counter": "Deflect, or quickly dodge forward for a free hit.", + "counter_zh": "弹刀,或者迅速向前闪避白打一下。", "common_mistakes": null, - "name_zh": "后撤横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "quad-shot", "name": "Quad Shot", + "name_zh": "四连射击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "At a distance, fires several rapid pistol shots followed by a large thrust from the spear. The last bullet is slightly delayed.", + "description_zh": "在远处快速开几枪,然后用长枪大幅突刺。最后一发子弹会稍有延迟。", "telegraph": "Pulls out a pistol.", + "telegraph_zh": "掏出手枪。", "counter": "Learn to deflect the shots, then Mikiri counter the following thrust or jump-attack if it's a sweep. Simply blocking the bullets damages posture heavily.", + "counter_zh": "学会弹开子弹,然后识破接下来的突刺;如果是横扫,就跳起攻击。只防御子弹会受到大量躯干伤害。", "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture.", - "name_zh": "四连射击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "漏掉稍有延迟的最后一发子弹,可能会被打破躯干。" } ] }, { "phase_number": 3, "name": "Phase 3", + "name_zh": "第三阶段", "moves": [ { "id": "dragon-flash", "name": "Dragon Flash", + "name_zh": "秘传·龙闪", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA", "move_type": "ranged", "description": "Strikes downward launching a force wave, followed by a second wave after a short delay.", + "description_zh": "向下斩击放出一道剑气,短暂延迟后再放出第二道。", "telegraph": "Sheathes his sword and readies a stance; a glint shows on the hilt.", + "telegraph_zh": "收刀入鞘摆出架势,刀柄上闪过一道光。", "counter": "Deflect the first wave, then dodge the second.", + "counter_zh": "弹开第一道剑气,然后闪避第二道。", "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", - "name_zh": "秘传·龙闪", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%A7%98%E4%BC%A0%C2%B7%E9%BE%99%E9%97%AA" + "common_mistakes_zh": "防御剑气而不是弹开——防御仍然会受到伤害。" }, { "id": "ashina-cross", "name": "Ashina Cross", + "name_zh": "绝技·苇名十字斩", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9", "move_type": "combo", "description": "Steps forward with two rapid slashes.", + "description_zh": "向前踏步,快速斩击两次。", "telegraph": "Sheathes his sword; after a short delay there is a flash of light at his hand as he steps forward.", + "telegraph_zh": "收刀入鞘;短暂延迟后,他向前踏步时手上会闪过一道光。", "counter": "Press deflect twice without pausing, or run far away to avoid it entirely.", + "counter_zh": "连续按两次弹刀,中间不要停顿;或者跑远完全躲开。", "common_mistakes": null, - "name_zh": "绝技·苇名十字斩", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E7%BB%9D%E6%8A%80%C2%B7%E8%8B%87%E5%90%8D%E5%8D%81%E5%AD%97%E6%96%A9" + "common_mistakes_zh": null }, { "id": "ashina-cross-cancel", "name": "Ashina Cross Cancel", + "name_zh": "苇名十字斩(取消)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "If the player is close when he readies Ashina Cross, he cancels it into a quick sword smack followed by a perilous sweep. He also does this after the player repeatedly deflects his attacks.", + "description_zh": "如果他准备苇名十字斩时玩家离得很近,他会中断改为快速一击,接一记危·下段横扫。玩家连续弹开他的攻击后,他也会这样做。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect the initial hit, then jump and stomp.", + "counter_zh": "弹开第一击,然后跳起踩他。", "common_mistakes": null, - "name_zh": "苇名十字斩(取消)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "ichimonji-double", "name": "Ichimonji Double", + "name_zh": "一字斩·二连", + "name_zh_source": "wiki", + "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E", "move_type": "overhead", "description": "Performs a double overhead strike.", + "description_zh": "连续两次下劈。", "telegraph": "Raises his blade into a ready stance.", + "telegraph_zh": "举刀摆出架势。", "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", + "counter_zh": "闪避或防御;也可以向侧面躲开。弹刀会让他硬直。", "common_mistakes": null, - "name_zh": "一字斩·二连", - "name_zh_source": "wiki", - "name_zh_source_url": "https://wiki.biligame.com/sekiro/%E4%B8%80%E5%AD%97%E6%96%A9%C2%B7%E4%BA%8C%E8%BF%9E" + "common_mistakes_zh": null }, { "id": "distance-slash", "name": "Distance Slash", + "name_zh": "远距离斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "If the player is far away or using an item, he quickly dashes in and slashes.", + "description_zh": "如果玩家离得很远或正在使用道具,他会迅速冲过来斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Maintain enough distance to react in time.", + "counter_zh": "保持足够的距离,以便及时反应。", "common_mistakes": null, - "name_zh": "远距离斩击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "charged-horizontal-force-wave", "name": "Charged Attack, Horizontal Force Wave", + "name_zh": "蓄力横向剑气", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Swings in a spinning motion, causing a large circular force wave.", + "description_zh": "旋转挥刀,放出一道巨大的环形剑气。", "telegraph": "Sheathes his sword and charges the attack.", + "telegraph_zh": "收刀入鞘,蓄力。", "counter": "Wait, then deflect as soon as he begins moving his arm, or sprint and jump toward him, then jump again onto his head.", + "counter_zh": "等待,在他开始挥动手臂时立刻弹刀;或者冲刺跳向他,再踩他的头跳起。", "common_mistakes": null, - "name_zh": "蓄力横向剑气", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "three-to-four-hit-combo", "name": "Three-to-Four Hit Combo", + "name_zh": "三至四连击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "A series of main slashes with delays between hits.", + "description_zh": "一连串主要斩击,每击之间有停顿。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect each hit; once he finishes slashing he's open for a quick two hits.", + "counter_zh": "逐一弹开;他砍完后会露出破绽,可以快速攻击两下。", "common_mistakes": null, - "name_zh": "三至四连击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "thrust", "name": "Thrust", + "name_zh": "突刺", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "thrust", "description": "A telegraphed thrust attack.", + "description_zh": "有明显预兆的突刺攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Mikiri Counter - easy to react to once telegraphed.", + "counter_zh": "识破——看到预兆后很容易反应。", "common_mistakes": null, - "name_zh": "突刺", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "long-range-wind-attack", "name": "Long Range Wind Attack", + "name_zh": "远距离风压攻击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "A long-range wind-based attack.", + "description_zh": "远距离的风压攻击。", "telegraph": null, + "telegraph_zh": null, "counter": "Dodge by running sideways to gain a couple of free hits.", + "counter_zh": "向侧面跑动躲开,可以白打几下。", "common_mistakes": null, - "name_zh": "远距离风压攻击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "close-range-full-screen-sweep", "name": "Close Range Full Screen Sweep", + "name_zh": "近距离全屏横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "A close-range sweep that covers the full screen.", + "description_zh": "覆盖全屏的近距离横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Sprint backwards before it goes off.", + "counter_zh": "在它发动前向后冲刺。", "common_mistakes": null, - "name_zh": "近距离全屏横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "charged-double-projected-force", "name": "Charge Attack, Double Projected Force", + "name_zh": "蓄力双重剑气", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "Makes two slashes that each launch a force disc. Deflecting the discs does not affect his posture.", + "description_zh": "斩击两次,每次放出一道圆盘状剑气。弹开剑气不会影响他的躯干。", "telegraph": "Sheathes his sword and charges the attack.", + "telegraph_zh": "收刀入鞘,蓄力。", "counter": "The discs deal damage through block if not deflected properly, but the attack is easy to dodge.", + "counter_zh": "如果没有正确弹开,剑气会穿透防御造成伤害,不过这招很容易闪避。", "common_mistakes": null, - "name_zh": "蓄力双重剑气", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "leaping-slam", "name": "Leaping Slam", + "name_zh": "跳跃砸击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "When far away or the player is using an item, he leaps and slams with his spear, followed by a sword strike or gunshots.", + "description_zh": "玩家离得很远或正在使用道具时,他会跃起用长枪砸下,之后接一记挥刀或枪击。", "telegraph": "Leaps into the air before slamming down with his spear.", + "telegraph_zh": "用长枪砸下前会先跃到空中。", "counter": "Deflect the slam, then counter-attack.", + "counter_zh": "弹开这一砸,然后反击。", "common_mistakes": null, - "name_zh": "跳跃砸击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "retreating-sweep", "name": "Retreating Sweep", + "name_zh": "后撤横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "Leaps backward into the air and sweeps with his spear, often following several blocked player attacks. Comes out very quickly.", + "description_zh": "向后跃到空中并用长枪横扫,常在玩家连续几次攻击被他防住后出现。出招非常快。", "telegraph": "Leaps backward into the air.", + "telegraph_zh": "向后跃到空中。", "counter": "Deflect, or quickly dodge forward for a free hit.", + "counter_zh": "弹刀,或者迅速向前闪避白打一下。", "common_mistakes": null, - "name_zh": "后撤横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "quad-shot", "name": "Quad Shot", + "name_zh": "四连射击", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "ranged", "description": "At a distance, fires several rapid pistol shots followed by a large thrust from the spear. The last bullet is slightly delayed.", + "description_zh": "在远处快速开几枪,然后用长枪大幅突刺。最后一发子弹会稍有延迟。", "telegraph": "Pulls out a pistol.", + "telegraph_zh": "掏出手枪。", "counter": "Learn to deflect the shots, then Mikiri counter the following thrust or jump-attack if it's a sweep. Simply blocking the bullets damages posture heavily.", + "counter_zh": "学会弹开子弹,然后识破接下来的突刺;如果是横扫,就跳起攻击。只防御子弹会受到大量躯干伤害。", "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture.", - "name_zh": "四连射击", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": "漏掉稍有延迟的最后一发子弹,可能会被打破躯干。" }, { "id": "lightning-slash", "name": "Lightning Slash", + "name_zh": "雷电斩", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "unblockable", "description": "Launches lightning at the player with a sword swing.", + "description_zh": "挥刀向玩家放出雷电。", "telegraph": "Jumps into the air as lightning converges on him.", + "telegraph_zh": "跃到空中,雷电向他聚集。", "counter": "Jump into the air and use Lightning Reversal, Mist Raven, or Sakura Dance to redirect the attack back at him and stun him.", + "counter_zh": "跳到空中,用雷反、雾鸦或秘传·樱舞把雷电返还给他,让他硬直。", "common_mistakes": null, - "name_zh": "雷电斩", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "lightning-overhead", "name": "Lightning-Enhanced Overhead Attacks", + "name_zh": "雷电强化下劈", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "overhead", "description": "Large overhead attacks with added lightning damage.", + "description_zh": "附带雷电伤害的大幅度下劈。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be deflected, though the timing is difficult.", + "counter_zh": "可以弹刀,但时机很难把握。", "common_mistakes": null, - "name_zh": "雷电强化下劈", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "lightning-spear-sweep", "name": "Large Sweep with Lightning-Covered Spear", + "name_zh": "雷枪大横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "sweep", "description": "A large swing with the spear covered in lightning.", + "description_zh": "用缠着雷电的长枪大幅横扫。", "telegraph": null, + "telegraph_zh": null, "counter": "Get close when he does this and jump to deflect it directly back at him for big vitality and posture damage, plus a flurry of free hits.", + "counter_zh": "他使用这招时贴近并跳起,把雷电直接返还给他,能造成大量体力和躯干伤害,还能白打一轮。", "common_mistakes": null, - "name_zh": "雷枪大横扫", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null }, { "id": "distance-slash-lightning", "name": "Distance Slash (Lightning Variant)", + "name_zh": "远距离斩击(雷电)", + "name_zh_source": "translation", + "name_zh_source_url": null, "move_type": "combo", "description": "More of a distance slash than the vertical Phase 1 wind attack.", + "description_zh": "与其说是第一阶段那种垂直的风压攻击,不如说更像远距离斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Similar avoidance strategy to the wind attacks - dodge by running sideways.", + "counter_zh": "躲法和风压攻击类似——向侧面跑动躲开。", "common_mistakes": null, - "name_zh": "远距离斩击(雷电)", - "name_zh_source": "translation", - "name_zh_source_url": null + "common_mistakes_zh": null } ] } diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 47211d6..0103218 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -128,3 +128,22 @@ def test_boss_data_is_public(anon_client): def test_attempts_and_analytics_require_login(anon_client, method, path): kwargs = {"json": {"result": "victory"}} if method == "post" else {} assert getattr(anon_client, method)(path, **kwargs).status_code == 401 + + +def test_language_preference_starts_unset_and_is_saved_on_the_account(anon_client): + assert register(anon_client).json()["preferred_language"] is None + + resp = anon_client.patch("/api/auth/me", json={"preferred_language": "zh"}) + + assert resp.status_code == 200 + assert resp.json()["preferred_language"] == "zh" + anon_client.cookies.clear() + login = anon_client.post("/api/auth/login", json=CREDENTIALS) + assert login.json()["preferred_language"] == "zh" + + +def test_language_preference_rejects_unknown_languages_and_requires_login(anon_client): + assert anon_client.patch("/api/auth/me", json={"preferred_language": "zh"}).status_code == 401 + + register(anon_client) + assert anon_client.patch("/api/auth/me", json={"preferred_language": "fr"}).status_code == 422 diff --git a/backend/tests/test_move_chinese_names.py b/backend/tests/test_move_chinese_names.py index 1601b83..10def39 100644 --- a/backend/tests/test_move_chinese_names.py +++ b/backend/tests/test_move_chinese_names.py @@ -75,3 +75,46 @@ def test_boss_detail_returns_chinese_names_and_their_sources(anon_client): assert moves["floating-passage"]["name_zh_source_url"].startswith("https://wiki.biligame.com/") assert moves["perilous-thrust"]["name_zh_source"] == "translation" assert moves["perilous-thrust"]["name_zh_source_url"] is None + + +# --- Bilingual content (V3 Milestone 4) ------------------------------------------- + + +def test_every_english_text_field_has_a_chinese_version_and_vice_versa(): + for boss in load_boss_data(): + assert boss.location_zh, boss.id + for phase in boss.phases: + assert phase.name_zh, f"{boss.id} phase {phase.phase_number}" + for move in phase.moves: + for field in ("description", "telegraph", "counter", "common_mistakes"): + english, chinese = getattr(move, field), getattr(move, f"{field}_zh") + assert (english is None) == (chinese is None), f"{boss.id}/{move.id}.{field}" + + +def test_seed_rejects_english_text_without_its_chinese_version(session): + data = load_boss_data() + move = next(m for p in data[0].phases for m in p.moves if m.description) + move.description_zh = None + + with pytest.raises(SeedError, match="description and description_zh must both be set"): + sync_reference_data(session, data) + + +def test_seed_rejects_a_boss_without_a_chinese_location(session): + data = load_boss_data() + data[0].location_zh = None + + with pytest.raises(SeedError, match="has no Chinese location"): + sync_reference_data(session, data) + + +def test_boss_detail_and_list_return_both_languages(anon_client): + boss = anon_client.get("/api/bosses/genichiro-ashina").json() + summary = next(b for b in anon_client.get("/api/bosses").json() if b["id"] == "genichiro-ashina") + floating = next(m for m in boss["phases"][0]["moves"] if m["id"] == "floating-passage") + + assert boss["location"] and boss["location_zh"] + assert summary["location_zh"] == boss["location_zh"] + assert boss["phases"][0]["name_zh"] == "第一阶段" + assert floating["description"] and floating["description_zh"] + assert floating["common_mistakes_zh"] diff --git a/backend/tests/test_sekiro_analytics.py b/backend/tests/test_sekiro_analytics.py index 50ba399..2760963 100644 --- a/backend/tests/test_sekiro_analytics.py +++ b/backend/tests/test_sekiro_analytics.py @@ -93,6 +93,7 @@ def test_recent_attempts_are_newest_first_with_names_and_capped_at_ten(client): "attempt_id": last["id"], "boss_id": "guardian-ape", "boss_name": "Guardian Ape", + "boss_name_zh": "狮子猿", "timestamp": None, "result": "failed", "phase_reached": 2, diff --git a/frontend/integration-tests/attemptWorkflow.test.ts b/frontend/integration-tests/attemptWorkflow.test.ts index 2031e0e..26e0ec4 100644 --- a/frontend/integration-tests/attemptWorkflow.test.ts +++ b/frontend/integration-tests/attemptWorkflow.test.ts @@ -305,4 +305,21 @@ describe("frontend calling the real backend", () => { await login({ username: "integration_wolf", password: "kusabimaru" }); expect(await getBossAttempts("genichiro-ashina")).toHaveLength(2); }); + + it("saves the interface language on the account and serves boss content in both languages", async () => { + const { getCurrentUser, updatePreferredLanguage } = await import("../src/api/auth"); + const { getBossById } = await import("../src/api/bosses"); + + expect((await getCurrentUser())?.preferred_language).toBeNull(); + await updatePreferredLanguage("zh"); + expect((await getCurrentUser())?.preferred_language).toBe("zh"); + + const boss = await getBossById("genichiro-ashina"); + const floatingPassage = boss.phases[0].moves.find((m) => m.id === "floating-passage"); + expect(boss.location_zh).toBeTruthy(); + expect(boss.phases[0].name_zh).toBe("第一阶段"); + expect(floatingPassage?.name_zh).toBe("绝技·飞渡浮舟"); + expect(floatingPassage?.description).toBeTruthy(); + expect(floatingPassage?.description_zh).toBeTruthy(); + }); }); diff --git a/frontend/src/api/auth.test.ts b/frontend/src/api/auth.test.ts index 02ea224..4877f3b 100644 --- a/frontend/src/api/auth.test.ts +++ b/frontend/src/api/auth.test.ts @@ -19,11 +19,11 @@ describe("auth api", () => { }); it("posts credentials to the login endpoint", async () => { - const fetchMock = mockFetch(200, { id: "1", username: "wolf" }); + const fetchMock = mockFetch(200, { id: "1", username: "wolf", preferred_language: null }); const user = await login({ username: "wolf", password: "kusabimaru" }); - expect(user).toEqual({ id: "1", username: "wolf" }); + expect(user).toEqual({ id: "1", username: "wolf", preferred_language: null }); expect(fetchMock).toHaveBeenCalledWith( "/api/auth/login", expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "wolf", password: "kusabimaru" }) }) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 0f8cb04..631622f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -13,6 +13,10 @@ export function logout(): Promise { return apiRequest("/auth/logout", { method: "POST" }); } +export function updatePreferredLanguage(language: "en" | "zh"): Promise { + return apiRequest("/auth/me", { method: "PATCH", body: JSON.stringify({ preferred_language: language }) }); +} + /** The logged-in user, or null when there is no valid session. */ export async function getCurrentUser(): Promise { try { diff --git a/frontend/src/api/bosses.test.ts b/frontend/src/api/bosses.test.ts index cdb9057..fe40aa2 100644 --- a/frontend/src/api/bosses.test.ts +++ b/frontend/src/api/bosses.test.ts @@ -10,7 +10,7 @@ describe("bosses api", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: async () => [{ id: "genichiro-ashina", name: "Genichiro Ashina", name_zh: "苇名弦一郎", location: "Ashina Castle" }], + json: async () => [{ id: "genichiro-ashina", name: "Genichiro Ashina", name_zh: "苇名弦一郎", location: "Ashina Castle", location_zh: null }], }); vi.stubGlobal("fetch", fetchMock); @@ -29,6 +29,7 @@ describe("bosses api", () => { name: "Genichiro Ashina", game: "sekiro", location: "Ashina Castle", + location_zh: null, phases: [], }), }); diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx index 60e94f1..50c844f 100644 --- a/frontend/src/auth/AuthProvider.test.tsx +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -30,7 +30,7 @@ function renderProbe() { describe("AuthProvider", () => { it("restores the logged-in user from the session cookie", async () => { - vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", username: "wolf" }); + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", username: "wolf", preferred_language: null }); renderProbe(); @@ -41,7 +41,7 @@ describe("AuthProvider", () => { it("logs in and out", async () => { vi.mocked(authApi.getCurrentUser).mockResolvedValue(null); - vi.mocked(authApi.login).mockResolvedValue({ id: "1", username: "wolf" }); + vi.mocked(authApi.login).mockResolvedValue({ id: "1", username: "wolf", preferred_language: null }); vi.mocked(authApi.logout).mockResolvedValue(undefined); renderProbe(); await screen.findByText("status: ready"); @@ -54,7 +54,7 @@ describe("AuthProvider", () => { }); it("forgets the user when any request comes back 401", async () => { - vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", username: "wolf" }); + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", username: "wolf", preferred_language: null }); renderProbe(); await screen.findByText("user: wolf"); diff --git a/frontend/src/auth/testAuth.tsx b/frontend/src/auth/testAuth.tsx index 93999cc..71b7f5e 100644 --- a/frontend/src/auth/testAuth.tsx +++ b/frontend/src/auth/testAuth.tsx @@ -4,7 +4,7 @@ import type { User } from "../types"; import { AuthContext } from "./authContext"; import type { AuthState } from "./authContext"; -export const testUser: User = { id: "1", username: "wolf" }; +export const testUser: User = { id: "1", username: "wolf", preferred_language: null }; /** Wraps `ui` in a fixed auth state for component tests, without touching the network. */ export function withAuth(ui: ReactElement, overrides: Partial = {}): ReactElement { diff --git a/frontend/src/components/AnalyticsPanel.test.tsx b/frontend/src/components/AnalyticsPanel.test.tsx index faf02be..6ffd989 100644 --- a/frontend/src/components/AnalyticsPanel.test.tsx +++ b/frontend/src/components/AnalyticsPanel.test.tsx @@ -8,19 +8,22 @@ const boss: Boss = { name: "Genichiro Ashina", game: "sekiro", location: "Ashina Castle", + location_zh: null, phases: [ { phase_number: 1, name: "Phase 1", + name_zh: null, moves: [ - { id: "perilous-thrust", name: "Perilous Thrust", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, + { id: "perilous-thrust", name: "Perilous Thrust", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null, description_zh: null, telegraph_zh: null, counter_zh: null, common_mistakes_zh: null }, ], }, { phase_number: 2, name: "Phase 2", + name_zh: null, moves: [ - { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, + { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null, description_zh: null, telegraph_zh: null, counter_zh: null, common_mistakes_zh: null }, ], }, ], diff --git a/frontend/src/components/AnalyticsPanel.tsx b/frontend/src/components/AnalyticsPanel.tsx index 59d66d7..1b6369d 100644 --- a/frontend/src/components/AnalyticsPanel.tsx +++ b/frontend/src/components/AnalyticsPanel.tsx @@ -1,3 +1,5 @@ +import { formatPhase, useLanguage } from "../i18n/language"; +import type { Translate } from "../i18n/language"; import type { Boss, BossAnalytics, RecentAnalytics } from "../types"; import { topKeys } from "../utils/analytics"; import { getMoveName } from "../utils/moves"; @@ -7,70 +9,74 @@ interface AnalyticsPanelProps { analytics: BossAnalytics; } -function describeTop(keys: string[], label: (key: string) => string, empty: string): string { +function describeTop(keys: string[], label: (key: string) => string, empty: string, t: Translate): string { if (keys.length === 0) return empty; - const names = keys.map(label).join(", "); - return keys.length > 1 ? `${names} (tied)` : names; + const names = keys.map(label).join(t("listSeparator")); + return keys.length > 1 ? t("tied", { names }) : names; } -function recentLabel(recent: RecentAnalytics): string { +function recentLabel(recent: RecentAnalytics, t: Translate): string { return recent.total_attempts < recent.window_size - ? `Last ${recent.total_attempts} (all so far)` - : `Last ${recent.window_size}`; + ? t("analytics.lastAllSoFar", { n: recent.total_attempts }) + : t("analytics.lastN", { n: recent.window_size }); } export default function AnalyticsPanel({ boss, analytics }: AnalyticsPanelProps) { + const { language, t } = useLanguage(); + if (analytics.total_attempts === 0) { return (
    -

    Your Analytics

    -

    Record an attempt to start seeing analytics.

    +

    {t("analytics.heading")}

    +

    {t("analytics.empty")}

    ); } const { recent } = analytics; - const recentHeader = recentLabel(recent); - const phaseLabel = (phase: string) => `Phase ${phase}`; - const moveLabel = (moveId: string) => getMoveName(boss, moveId) ?? moveId; + const recentHeader = recentLabel(recent, t); + const phaseLabel = (phase: string) => formatPhase(phase, language); + const moveLabel = (moveId: string) => getMoveName(boss, moveId, language) ?? moveId; const phaseRows = Object.keys(analytics.failure_by_phase).sort((a, b) => Number(a) - Number(b)); const moveRows = Object.keys(analytics.failure_by_move).sort( (a, b) => analytics.failure_by_move[b] - analytics.failure_by_move[a] ); + const notApplicable = t("analytics.notApplicable"); + const notEnoughData = t("analytics.notEnoughData"); return (
    -

    Your Analytics

    +

    {t("analytics.heading")}

    -

    Main Bottleneck

    +

    {t("analytics.mainBottleneck")}

    -
    All attempts
    -
    {describeTop(topKeys(analytics.failure_by_phase), phaseLabel, "N/A")}
    +
    {t("analytics.allAttempts")}
    +
    {describeTop(topKeys(analytics.failure_by_phase), phaseLabel, notApplicable, t)}
    {recentHeader}
    -
    {describeTop(topKeys(recent.failure_by_phase), phaseLabel, "N/A")}
    +
    {describeTop(topKeys(recent.failure_by_phase), phaseLabel, notApplicable, t)}
    -

    Most Common Failure

    +

    {t("analytics.mostCommonFailure")}

    -
    All attempts
    -
    {describeTop(topKeys(analytics.failure_by_move), moveLabel, "Not enough data")}
    +
    {t("analytics.allAttempts")}
    +
    {describeTop(topKeys(analytics.failure_by_move), moveLabel, notEnoughData, t)}
    {recentHeader}
    -
    {describeTop(topKeys(recent.failure_by_move), moveLabel, "Not enough data")}
    +
    {describeTop(topKeys(recent.failure_by_move), moveLabel, notEnoughData, t)}
    {phaseRows.length > 0 && ( - + - - + + @@ -88,11 +94,11 @@ export default function AnalyticsPanel({ boss, analytics }: AnalyticsPanelProps) {moveRows.length > 0 && (
    Failure Breakdown by Phase{t("analytics.byPhase")}
    PhaseAll attempts{t("analytics.phase")}{t("analytics.allAttempts")} {recentHeader}
    - + - - + + diff --git a/frontend/src/components/AttemptHistory.test.tsx b/frontend/src/components/AttemptHistory.test.tsx index c0b31fc..3622e5c 100644 --- a/frontend/src/components/AttemptHistory.test.tsx +++ b/frontend/src/components/AttemptHistory.test.tsx @@ -8,12 +8,14 @@ const boss: Boss = { name: "Genichiro Ashina", game: "sekiro", location: "Ashina Castle", + location_zh: null, phases: [ { phase_number: 2, name: "Phase 2", + name_zh: null, moves: [ - { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, + { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null, description_zh: null, telegraph_zh: null, counter_zh: null, common_mistakes_zh: null }, ], }, ], diff --git a/frontend/src/components/AttemptHistory.tsx b/frontend/src/components/AttemptHistory.tsx index bb9691b..d30eb5f 100644 --- a/frontend/src/components/AttemptHistory.tsx +++ b/frontend/src/components/AttemptHistory.tsx @@ -1,3 +1,4 @@ +import { formatPhase, useLanguage } from "../i18n/language"; import type { Attempt, Boss } from "../types"; import { getFailureLabel } from "../utils/moves"; @@ -7,22 +8,26 @@ interface AttemptHistoryProps { } export default function AttemptHistory({ boss, attempts }: AttemptHistoryProps) { + const { language, t } = useLanguage(); + return (
    -

    Recent Attempts

    +

    {t("history.heading")}

    {attempts.length === 0 ? ( -

    No attempts recorded yet.

    +

    {t("history.empty")}

    ) : (
      {attempts.map((attempt, index) => (
    • #{attempts.length - index} - {attempt.result === "victory" ? "Victory" : `Failed — Phase ${attempt.phase_reached}`} + {attempt.result === "victory" + ? t("result.victory") + : t("result.failedAt", { phase: formatPhase(attempt.phase_reached, language) })} {attempt.result === "failed" && ( - {getFailureLabel(boss, attempt)} + {getFailureLabel(boss, attempt, t, language)} )}
    • ))} diff --git a/frontend/src/components/BossList.tsx b/frontend/src/components/BossList.tsx index 93ce580..1d81007 100644 --- a/frontend/src/components/BossList.tsx +++ b/frontend/src/components/BossList.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { getBosses } from "../api/bosses"; +import { bossLocation, bossName } from "../i18n/content"; +import { useLanguage } from "../i18n/language"; import type { BossSummary } from "../types"; type LoadState = "loading" | "error" | "ready"; export default function BossList() { + const { language, t } = useLanguage(); const [bosses, setBosses] = useState([]); const [state, setState] = useState("loading"); @@ -29,18 +32,17 @@ export default function BossList() { }; }, []); - if (state === "loading") return

      Loading bosses…

      ; - if (state === "error") return

      Failed to load bosses.

      ; + if (state === "loading") return

      {t("bossList.loading")}

      ; + if (state === "error") return

      {t("bossList.error")}

      ; return (
        {bosses.map((boss) => (
      • -

        {boss.name}

        - {boss.name_zh &&

        {boss.name_zh}

        } -

        {boss.location}

        +

        {bossName(boss, language)}

        +

        {bossLocation(boss, language)}

        - View Boss + {t("bossList.view")}
      • ))} diff --git a/frontend/src/components/ChineseMoveName.tsx b/frontend/src/components/ChineseMoveName.tsx deleted file mode 100644 index bcd61c9..0000000 --- a/frontend/src/components/ChineseMoveName.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { BossMove } from "../types"; - -const TRANSLATION_NOTE = "Translated for this app; not an official in-game name."; - -/** - * A move's Chinese name next to its English one. Names taken from a Chinese wiki - * link to that page; translations are labelled so they are never mistaken for - * official names. - */ -export default function ChineseMoveName({ move }: { move: BossMove }) { - if (!move.name_zh) return null; - - if (move.name_zh_source === "wiki" && move.name_zh_source_url) { - return ( - - - {move.name_zh} - - - ); - } - - return ( - - {move.name_zh} - - 译名 - - - ); -} diff --git a/frontend/src/components/MovesetReference.test.tsx b/frontend/src/components/MovesetReference.test.tsx index 4341f24..56c1271 100644 --- a/frontend/src/components/MovesetReference.test.tsx +++ b/frontend/src/components/MovesetReference.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import MovesetReference from "./MovesetReference"; +import { withLanguage } from "../i18n/testLanguage"; import type { Boss } from "../types"; const boss: Boss = { @@ -9,12 +10,14 @@ const boss: Boss = { name_zh: "剑圣 苇名一心", game: "sekiro", location: "Ashina Reservoir", + location_zh: null, source_name: "Fextralife Sekiro Wiki", source_url: "https://sekiroshadowsdietwice.wiki.fextralife.com/Isshin,+the+Sword+Saint", phases: [ { phase_number: 1, name: "Phase 1", + name_zh: null, moves: [ { id: "dragon-flash", @@ -27,6 +30,10 @@ const boss: Boss = { name_zh: "秘传·龙闪", name_zh_source: "wiki", name_zh_source_url: "https://wiki.biligame.com/sekiro/dragon-flash", + description_zh: "向下斩击放出一道剑气。", + telegraph_zh: "刀柄上闪过一道光。", + counter_zh: "弹开第一道剑气。", + common_mistakes_zh: "防御剑气而不是弹开。", }, { id: "thrust", @@ -39,6 +46,10 @@ const boss: Boss = { name_zh: "突刺", name_zh_source: "translation", name_zh_source_url: null, + description_zh: null, + telegraph_zh: null, + counter_zh: null, + common_mistakes_zh: null, }, ], }, @@ -60,7 +71,7 @@ describe("MovesetReference", () => { const thrust = screen.getByText("Thrust").closest("li") as HTMLElement; // Only the names: no description, telegraph, counter or common-mistake paragraphs. expect(thrust.querySelectorAll("p")).toHaveLength(0); - expect(thrust.textContent).toBe("Thrust突刺译名"); + expect(thrust.textContent).toBe("Thrust"); }); it("links to the boss's data source", () => { @@ -75,18 +86,34 @@ describe("MovesetReference", () => { expect(screen.queryByText(/source:/i)).not.toBeInTheDocument(); }); - it("links Chinese names taken from a wiki to their source page", () => { + it("shows only English in English mode", () => { render(); - const link = screen.getByRole("link", { name: "秘传·龙闪" }); - expect(link).toHaveAttribute("href", "https://wiki.biligame.com/sekiro/dragon-flash"); + expect(screen.getByText("Dragon Flash")).toBeInTheDocument(); + expect(screen.queryByText("秘传·龙闪")).not.toBeInTheDocument(); + expect(screen.queryByText(/向下斩击/)).not.toBeInTheDocument(); }); - it("marks translated Chinese names as translations", () => { - render(); + it("shows only Chinese in Chinese mode, with one note about translations", () => { + render(withLanguage(, "zh")); + + expect(screen.getByRole("heading", { name: "Boss 招式" })).toBeInTheDocument(); + expect(screen.getByText("向下斩击放出一道剑气。")).toBeInTheDocument(); + expect(screen.getByText("前摇:刀柄上闪过一道光。")).toBeInTheDocument(); + expect(screen.getByText("常见失误:防御剑气而不是弹开。")).toBeInTheDocument(); + expect(screen.queryByText("Dragon Flash")).not.toBeInTheDocument(); + expect(screen.queryByText(/Launches a force wave/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/大多由本站译自/)).toHaveLength(1); + }); + + it("links a Chinese name taken from a wiki to its page, and leaves translations unlinked", () => { + render(withLanguage(, "zh")); + expect(screen.getByRole("link", { name: "秘传·龙闪" })).toHaveAttribute( + "href", + "https://wiki.biligame.com/sekiro/dragon-flash" + ); expect(screen.getByText("突刺")).toBeInTheDocument(); expect(screen.queryByRole("link", { name: "突刺" })).not.toBeInTheDocument(); - expect(screen.getByTitle(/not an official in-game name/)).toHaveTextContent("译名"); }); }); diff --git a/frontend/src/components/MovesetReference.tsx b/frontend/src/components/MovesetReference.tsx index ef4b316..4b3e83a 100644 --- a/frontend/src/components/MovesetReference.tsx +++ b/frontend/src/components/MovesetReference.tsx @@ -1,32 +1,58 @@ -import type { Boss } from "../types"; -import ChineseMoveName from "./ChineseMoveName"; +import { moveName, moveText, phaseName } from "../i18n/content"; +import { useLanguage } from "../i18n/language"; +import type { Boss, BossMove } from "../types"; + +function MoveTitle({ move }: { move: BossMove }) { + const { language, t } = useLanguage(); + const name = moveName(move, language); + + // In Chinese, a name taken from a Chinese wiki links to that page. + if (language === "zh" && move.name_zh_source === "wiki" && move.name_zh_source_url) { + return ( + + + {name} + + + ); + } + return {name}; +} export default function MovesetReference({ boss }: { boss: Boss }) { + const { language, t } = useLanguage(); + return (
        -

        Boss Moveset

        +

        {t("moveset.heading")}

        + {language === "zh" &&

        {t("moveset.translationNote")}

        } {boss.phases.map((phase) => (
        -

        {phase.name}

        +

        {phaseName(phase, language)}

          - {phase.moves.map((move) => ( -
        • - {move.name} - - {move.description &&

          {move.description}

          } - {move.telegraph &&

          Telegraph: {move.telegraph}

          } - {move.counter &&

          Counter: {move.counter}

          } - {move.common_mistakes &&

          Common mistake: {move.common_mistakes}

          } -
        • - ))} + {phase.moves.map((move) => { + const description = moveText(move, "description", language); + const telegraph = moveText(move, "telegraph", language); + const counter = moveText(move, "counter", language); + const mistakes = moveText(move, "common_mistakes", language); + return ( +
        • + + {description &&

          {description}

          } + {telegraph &&

          {t("moveset.telegraph", { text: telegraph })}

          } + {counter &&

          {t("moveset.counter", { text: counter })}

          } + {mistakes &&

          {t("moveset.commonMistake", { text: mistakes })}

          } +
        • + ); + })}
        ))} {boss.source_url && (

        - Source:{" "} + {t("moveset.source")}{" "} {boss.source_name ?? boss.source_url} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 9dd3f3d..0a911d5 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -1,8 +1,10 @@ import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "../auth/authContext"; +import { useLanguage } from "../i18n/language"; export default function NavBar() { const { status, user, logout } = useAuth(); + const { language, setLanguage, t } = useLanguage(); const navigate = useNavigate(); async function handleLogout() { @@ -10,33 +12,43 @@ export default function NavBar() { navigate("/"); } + const otherLanguage = language === "en" ? "zh" : "en"; + return (

        -
        ); } diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx index d185eac..7b69466 100644 --- a/frontend/src/components/PasswordInput.tsx +++ b/frontend/src/components/PasswordInput.tsx @@ -1,4 +1,5 @@ import { useId, useState } from "react"; +import { useLanguage } from "../i18n/language"; interface PasswordInputProps { value: string; @@ -24,12 +25,14 @@ function EyeIcon({ crossed }: { crossed: boolean }) { /** A labelled password field with a button that shows or hides what was typed. */ export default function PasswordInput({ value, onChange, autoComplete }: PasswordInputProps) { + const { t } = useLanguage(); const id = useId(); const [visible, setVisible] = useState(false); + const toggleLabel = visible ? t("password.hide") : t("password.show"); return (
        - +
        setVisible((v) => !v)} - aria-label={visible ? "Hide password" : "Show password"} + aria-label={toggleLabel} aria-pressed={visible} - title={visible ? "Hide password" : "Show password"} + title={toggleLabel} > {/* Crossed-out eye while the password is visible: the button hides it again. */} diff --git a/frontend/src/components/ProgressionChart.test.tsx b/frontend/src/components/ProgressionChart.test.tsx index 99ad85a..965bf85 100644 --- a/frontend/src/components/ProgressionChart.test.tsx +++ b/frontend/src/components/ProgressionChart.test.tsx @@ -9,11 +9,12 @@ const boss: Boss = { name_zh: null, game: "sekiro", location: "Hirata Estate", + location_zh: null, source_name: null, source_url: null, phases: [ - { phase_number: 1, name: "Phase 1", moves: [] }, - { phase_number: 2, name: "Phase 2", moves: [] }, + { phase_number: 1, name: "Phase 1", name_zh: null, moves: [] }, + { phase_number: 2, name: "Phase 2", name_zh: null, moves: [] }, ], }; diff --git a/frontend/src/components/ProgressionChart.tsx b/frontend/src/components/ProgressionChart.tsx index 6c4db40..3dd6fdd 100644 --- a/frontend/src/components/ProgressionChart.tsx +++ b/frontend/src/components/ProgressionChart.tsx @@ -1,3 +1,4 @@ +import { formatPhase, useLanguage } from "../i18n/language"; import type { Boss, ProgressionPoint } from "../types"; const X_STEP = 28; @@ -12,11 +13,13 @@ interface ProgressionChartProps { } export default function ProgressionChart({ boss, points }: ProgressionChartProps) { + const { language, t } = useLanguage(); + if (points.length === 0) { return (
        -

        Progression

        -

        Record an attempt to see your progression.

        +

        {t("progression.heading")}

        +

        {t("progression.empty")}

        ); } @@ -32,23 +35,29 @@ export default function ProgressionChart({ boss, points }: ProgressionChartProps const labelEvery = Math.ceil(points.length / MAX_X_LABELS); const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.attempt_number)},${y(p.phase_reached)}`).join(" "); const victories = points.filter((p) => p.result === "victory").length; + const pointLabel = (p: ProgressionPoint) => + t("progression.point", { + n: p.attempt_number, + phase: formatPhase(p.phase_reached, language), + result: p.result === "victory" ? t("progression.victory") : t("progression.failed"), + }); return (
        -

        Progression

        +

        {t("progression.heading")}

        {phaseNumbers.map((phase) => ( - P{phase} + {t("progression.axisPhase", { n: phase })} ))} @@ -73,9 +82,9 @@ export default function ProgressionChart({ boss, points }: ProgressionChartProps cx={x(p.attempt_number)} cy={y(p.phase_reached)} r={p.result === "victory" ? 7 : 5} - aria-label={`Attempt ${p.attempt_number}: Phase ${p.phase_reached}, ${p.result}`} + aria-label={pointLabel(p)} > - {`Attempt ${p.attempt_number}: Phase ${p.phase_reached}, ${p.result}`} + {pointLabel(p)} ))} diff --git a/frontend/src/components/RecordAttemptForm.test.tsx b/frontend/src/components/RecordAttemptForm.test.tsx index 706df1d..c5b3431 100644 --- a/frontend/src/components/RecordAttemptForm.test.tsx +++ b/frontend/src/components/RecordAttemptForm.test.tsx @@ -13,19 +13,22 @@ const boss: Boss = { name: "Genichiro Ashina", game: "sekiro", location: "Ashina Castle", + location_zh: null, phases: [ { phase_number: 1, name: "Phase 1", + name_zh: null, moves: [ - { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, + { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null, description_zh: null, telegraph_zh: null, counter_zh: null, common_mistakes_zh: null }, ], }, { phase_number: 2, name: "Phase 2", + name_zh: null, moves: [ - { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null }, + { id: "floating-passage", name: "Floating Passage", move_type: "combo", description: null, telegraph: null, counter: null, common_mistakes: null, name_zh: null, name_zh_source: null, name_zh_source_url: null, description_zh: null, telegraph_zh: null, counter_zh: null, common_mistakes_zh: null }, ], }, ], diff --git a/frontend/src/components/RecordAttemptForm.tsx b/frontend/src/components/RecordAttemptForm.tsx index f4f7ce1..2ff9b11 100644 --- a/frontend/src/components/RecordAttemptForm.tsx +++ b/frontend/src/components/RecordAttemptForm.tsx @@ -3,7 +3,8 @@ import type { FormEvent } from "react"; import { createAttempt } from "../api/attempts"; import { ApiError } from "../api/client"; import type { AttemptResult, Boss, CreateAttemptRequest } from "../types"; -import { moveLabel } from "../utils/moves"; +import { bossName, moveName, phaseName } from "../i18n/content"; +import { useLanguage } from "../i18n/language"; const OTHER = "__other__"; const NOT_SURE = "__not_sure__"; @@ -15,6 +16,7 @@ interface RecordAttemptFormProps { } export default function RecordAttemptForm({ boss, onSuccess, onCancel }: RecordAttemptFormProps) { + const { language, t } = useLanguage(); const [result, setResult] = useState("failed"); const [phaseReached, setPhaseReached] = useState(boss.phases[0]?.phase_number ?? 1); const [failureChoice, setFailureChoice] = useState(""); @@ -55,16 +57,17 @@ export default function RecordAttemptForm({ boss, onSuccess, onCancel }: RecordA onSuccess(); } catch (err) { setSubmitting(false); - setError(err instanceof ApiError ? err.message : "Failed to save attempt."); + // Backend validation messages are English; Chinese mode shows a generic message instead. + setError(err instanceof ApiError && language === "en" ? err.message : t("form.saveError")); } } return (
        -

        Record Attempt — {boss.name}

        +

        {t("form.title", { boss: bossName(boss, language) })}

        - Result + {t("form.result")}
        {result === "failed" && (
    Failure Breakdown by Move{t("analytics.byMove")}
    MoveAll attempts{t("analytics.move")}{t("analytics.allAttempts")} {recentHeader}