diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbcc8c0..5f1ad6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,7 @@ jobs: - name: Wait for the backend to become healthy run: | + cat > wait-for-backend.sh <<'EOF' for i in $(seq 1 60); do if curl -sf http://localhost:8000/health > /dev/null; then echo "backend healthy after ${i} tries" @@ -160,6 +161,8 @@ jobs: done echo "backend did not become healthy" >&2 exit 1 + EOF + bash wait-for-backend.sh - name: Smoke test through nginx run: | @@ -167,7 +170,34 @@ 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}" + + # An attempt for the persistence check below. + curl -sf -b cookies.txt -H 'Content-Type: application/json' \ + -d '{"result": "failed", "phase_reached": 1, "failure_move_id": null, "failure_category": "not_sure", "notes": "persistence check"}' \ + http://localhost:8080/api/bosses/genichiro-ashina/attempts > /dev/null + + # Stopping the stack without -v must keep accounts and attempts in the + # postgres-data volume (V3 success criteria: restart, data persists). + - name: Restart the stack and check the data persisted + run: | + docker compose down + docker compose up -d + bash wait-for-backend.sh + + curl -sf -c cookies-after.txt -H 'Content-Type: application/json' \ + -d '{"username": "ci_smoke", "password": "ci-smoke-password"}' \ + http://localhost:8080/api/auth/login > /dev/null + curl -sf -b cookies-after.txt http://localhost:8080/api/bosses/genichiro-ashina/attempts \ + | jq -e 'length == 1 and .[0].notes == "persistence check"' - name: Show container logs if: failure() diff --git a/CLAUDE.md b/CLAUDE.md index 7926303..b7eca78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,278 +1,153 @@ -# Sekiro Boss Practice Analytics +# Sekiro Boss Practice Analytics — Claude Development Instructions -## Project Overview +## 1. Project Overview -Sekiro Boss Practice Analytics is a web application for tracking and analyzing player attempts against bosses in *Sekiro: Shadows Die Twice*. +Sekiro Boss Practice Analytics is a full-stack application for tracking and eventually automatically analyzing player performance against bosses in *Sekiro: Shadows Die Twice*. -Players manually record information they can realistically remember after each boss attempt: +The project began as a lightweight manual attempt tracker. -* whether the attempt ended in victory or failure -* which phase they reached -* which move caused the final failure, if known -* optional notes +The long-term direction is now: -The application derives analytics from this attempt history to identify: +```text +Manual Attempt Tracking + ↓ +Structured Multi-User Product + ↓ +Verified Combat Ground Truth + ↓ +Gameplay Video Analysis + ↓ +Automatic Practice Analytics +``` -* frequently recorded failure moves -* bottleneck phases -* progression across attempts -* overall boss progression +The project must evolve incrementally. + +Do not pull later-stage technology into earlier versions without a concrete requirement. --- -# Current Project Status +# 2. Current Project Status ## V1 — Completed -V1 established the complete MVP workflow: +V1 established the full manual-attempt workflow: ```text Choose Boss - ↓ -Boss Dashboard - ↓ -Record Attempt - ↓ -Result + Phase + Failure Move - ↓ -Attempt History - ↓ -Basic Analytics +→ Boss Dashboard +→ Record Attempt +→ Result +→ Phase Reached +→ Failure Move / Other / Not Sure +→ Save +→ History +→ Analytics ``` -V1 uses: +V1 used: -* React + TypeScript -* FastAPI + Pydantic -* JSON persistence -* GitHub Actions CI -* Docker - -V1 intentionally validated one complete vertical slice before expanding the system. - -## V2 — Completed - -V2 delivered the structured, multi-boss Sekiro analytics application: - -* PostgreSQL persistence with SQLAlchemy and Alembic (Plan B schema: moves stored once per boss, linked to phases through `phase_moves`) -* 8 bosses: Genichiro Ashina, Owl (Father), Lady Butterfly, Guardian Ape, Corrupted Monk, True Corrupted Monk, Great Shinobi Owl, Isshin, the Sword Saint -* richer move metadata with a source page for each boss -* progression analytics: progression chart, all-time vs. last 10 attempts, attempts until first victory -* an overall Sekiro dashboard at `/`, with the boss list at `/bosses` -* 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. - -The V2 sections below remain the reference for how the existing code was built. V3 scope has not been written into this file yet. +- React +- TypeScript +- FastAPI +- Pydantic +- JSON persistence +- GitHub Actions +- Docker --- -# V2 Scope (Completed) +## V2 — Completed -This section records what V2 covered: +V2 migrated the application to a structured relational platform. -> **V2 — Structured Sekiro Analytics Platform** +Delivered: -V2 evolved the JSON-backed MVP into a structured, multi-boss Sekiro analytics application. +- PostgreSQL +- SQLAlchemy +- Alembic +- relational boss / phase / move data +- `phase_moves` mapping +- multiple Sekiro bosses +- richer move metadata +- progression analytics +- recent vs. historical analytics +- Sekiro-level dashboard +- integration tests +- Docker Compose +- four required CI jobs -The main V2 transition is: +Released as: ```text -V1 - -Small boss dataset -+ -JSON persistence -+ -basic boss-level analytics - - ↓ - -V2 - -Multiple bosses -+ -PostgreSQL -+ -relational data model -+ -progression analytics -+ -game-level analytics -+ -local Docker deployment +v2.0.0 ``` --- -# V2 Product Goal - -V2 should answer: - -> **Where am I improving or struggling across Sekiro?** - -V1 primarily answers: - -> What killed me? - -V2 should preserve that functionality while adding: - -* multiple major Sekiro bosses -* relational persistence -* richer boss metadata -* progression analytics -* 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) - ---- +## V3 — Completed -# V2 Core Product Principles +V3 was re-scoped during development. -## Preserve Lightweight Attempt Recording - -The most important V1 interaction remains unchanged. - -Users should still be able to record an attempt quickly using: +The old V3 goal was: ```text -Result -Phase Reached -Failure Move / Other / Not Sure -Optional Notes +Personalized Practice Coach ``` -Do NOT expand attempt recording into detailed manual combat telemetry. - -Do NOT require users to remember: - -* every move encountered -* total deflect attempts -* Mikiri success count -* healing usage -* damage taken -* posture events -* move frequency -* detailed per-action performance - -Those belong to gameplay analysis in later versions. - ---- - -## Prefer Derived Analytics - -The system should derive weaknesses from attempt history whenever possible. - -Do not ask users to manually declare: +The new V3 goal is: ```text -"My weakest move is Floating Passage." +Multi-User Product Foundation ``` -Prefer computing: +This change is intentional. -```text -Most Common Recorded Failure: -Floating Passage -``` +During content expansion, Wiki-derived movesets were found to be incomplete or inconsistent. -from attempt data. +Therefore: ---- +> V3 must not pretend Wiki content is complete engine-level ground truth. -## Maintain Honest Analytics +Ground-truth completeness is now explicitly a V4 concern. -Do not calculate or display statistics that the available data cannot support. +Delivered: -For example: +- accounts and authentication (argon2, server-side sessions in an httpOnly cookie) +- user-owned attempts, with ownerless pre-account attempts kept until claimed +- per-user analytics and isolation +- English / 中文 interface +- existing-content cleanup, plus a cross-wiki move mapping for every boss in `research/ground_truth/semantic/wiki_mapping/` +- four required CI jobs, including a check that data survives a Docker restart -```text -Floating Passage failures: 5 -``` - -is valid. - -But: +Released as: ```text -Floating Passage success rate: 75% +v3.0.0 ``` -is NOT valid unless the system knows how many times Floating Passage actually occurred. - -True per-move success rates belong to V4 gameplay analysis. - --- -# V2 Non-Goals +# 3. Current Development Focus -Do NOT introduce the following unless explicitly requested: +## V4 — Combat Ground Truth Foundation -* user accounts -* authentication -* OAuth -* personalized multi-user histories -* recommendation engines -* gameplay video uploads -* computer vision -* machine learning -* automatic boss move detection -* automatic player action recognition -* Black Myth: Wukong support -* multi-game UI -* Redis without a demonstrated need -* Kafka or message queues without a demonstrated need -* microservices -* Kubernetes purely for complexity -* detailed combat telemetry +V4 answers: -These belong to V3–V5 or should only be introduced when justified by an actual technical requirement. +> **What boss actions actually exist, and what evidence supports the semantic moves used by the product?** ---- +Start with V4 Milestone 1 (research foundation) and Milestone 2 (Genichiro runtime pilot). See sections 18–36. -# V2 Tech Stack +V3 is released. Sections 7–17 remain the reference for how the current product is built; their product rules (lightweight attempt recording, honest analytics, per-user isolation, one language per page) still apply. -## Frontend +Public deployment is V4 Milestone 9. -* React -* TypeScript -* Vite - -## Backend - -* Python -* FastAPI -* Pydantic - -## Database - -* PostgreSQL -* SQLAlchemy -* Alembic - -## Testing - -* pytest -* API tests -* database integration tests -* frontend type checking -* frontend build validation - -## CI/CD - -* GitHub Actions - -## Deployment - -* 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. +Practice recommendations are part of V5. --- -# V2 High-Level Architecture +# 4. Current Architecture + +The production application follows: ```text Browser @@ -290,78 +165,46 @@ SQLAlchemy PostgreSQL ``` -Analytics should conceptually follow: +Docker Compose provides the local application stack. -```text -FastAPI - ↓ -Analytics Service - ↓ -SQLAlchemy Queries - ↓ -PostgreSQL Attempt Data -``` - -The frontend must remain independent of the persistence implementation. - ---- - -# API Compatibility Principle - -The V1 frontend already communicates with FastAPI. - -The PostgreSQL migration should preserve existing API contracts wherever practical. - -Conceptually: - -```text -V1 +The frontend must not depend directly on PostgreSQL or raw persistence details. -React - ↓ -FastAPI - ↓ -JSON -``` - -becomes: +The backend should preserve clear separation between: ```text -V2 - -React - ↓ -FastAPI - ↓ -PostgreSQL +routers +services +database access +domain / API models ``` -The frontend should not require a large rewrite simply because persistence changes. - -If an API contract must change, make the change deliberately and update frontend types and clients together. +Do not place substantial database or analytics logic directly inside route handlers. --- -# V2 Core Domain Model +# 5. Current Core Domain Model -The primary domain hierarchy remains: +The current product model is conceptually: ```text Game └── Boss - ├── Phase + ├── BossPhase └── Move -Phase ←── many-to-many ──→ Move +BossPhase + ↕ +PhaseMove + ↕ +Move ``` -A move belongs to a boss and may appear in several of that boss's phases. - -Player attempt data: +Player data: ```text -Boss +User └── Attempt + ├── Boss ├── Result ├── Phase Reached ├── Failure Move @@ -370,1497 +213,1463 @@ Boss └── Timestamp ``` ---- - -# V2 Relational Data Model - -The initial PostgreSQL schema should approximately contain: +Authentication: ```text -games -bosses -boss_phases -moves -phase_moves -attempts +User + └── UserSession ``` -Avoid adding tables solely for hypothetical future requirements. +--- -`phase_moves` is not hypothetical: the existing boss data already reuses the same move across multiple phases (for example, Owl (Father)'s Shadowfall appears in both phases). +# 6. Important Meaning of `Move` ---- +The current production `Move` model should be understood as: -## Games +> **a human-readable semantic move** -Conceptual fields: +Examples: ```text -id -slug -name +Floating Passage +Perilous Thrust +Sweep +Lightning Attack ``` -Example: +It is **not** currently: + +- a raw engine animation +- a TAE event +- a behavior state +- an HKS event +- an attack parameter +- a guaranteed one-to-one gameplay action + +Do not assume: ```text -1 -sekiro -Sekiro: Shadows Die Twice +Move += +Animation ``` -Even though V2 supports only Sekiro, retaining the Game entity keeps the domain model clean. - -Do NOT build full multi-game functionality in V2. +This distinction becomes critical in V4. --- -## Bosses +# 7. V3 Product Principles + +## 7.1 Preserve Lightweight Attempt Recording -Conceptual fields: +The manual attempt form remains: ```text -id -game_id -slug -name -name_zh -location -source_name -source_url +Result +Phase Reached +Failure Move / Other / Not Sure +Optional Notes ``` -`name_zh` is the official Simplified Chinese name. Only boss names are localized; move names and the UI stay in English for now. +Do not require users to manually record: -`source_name` and `source_url` record where the boss's phase and move data came from. All moves of a boss share this source. +- every move encountered +- number of deflects +- Mikiri attempts +- posture events +- healing usage +- every player response +- detailed combat timelines -Relationship: - -```text -Game - 1 - ↓ -Many Bosses -``` +Those belong to future gameplay observation. --- -## Boss Phases +## 7.2 Preserve Honest Analytics -Conceptual fields: +The application may derive: ```text -id -boss_id -phase_number -name +Floating Passage recorded failures: 5 ``` -Relationship: +It must not claim: ```text -Boss - 1 - ↓ -Many Phases +Floating Passage success rate: 75% ``` -A boss may have a different number of phases from another boss. +unless the system knows how many times the move actually occurred. -Do not assume every boss has exactly three phases. +Before V5 automated gameplay observation, the denominator usually does not exist. --- -## Moves +## 7.3 Per-User Isolation Is Mandatory -Conceptual fields: +Every user-owned attempt query must be scoped to the authenticated user. -```text -id -boss_id -slug -name -move_type -description -telegraph -counter -common_mistakes -``` +A user must never: -Moves do not have their own source fields; they use their boss's `source_name` and `source_url`. Add move-level sources only if a move's data ever comes from a different page than its boss. +- read another user's attempts +- alter another user's attempts +- affect another user's analytics +- submit another user's `user_id` -Only fill `telegraph` and `common_mistakes` from what the source explicitly states. Leave them null rather than inferring them. +Boss reference content remains global. -Relationship: +User attempts remain personal. -```text -Boss - 1 - ↓ -Many Moves -``` +--- + +# 8. V3 Milestone 1 — Accounts and Authentication ✅ + +Already completed. -Each move is stored once per boss, even if it appears in several phases. `slug` should be unique within a boss. +Current expected behavior includes: -Not every optional field must be populated immediately. +- registration +- login +- logout +- current-user lookup +- argon2 password hashing +- server-side sessions +- httpOnly cookie +- SameSite behavior appropriate for same-origin deployment +- only a hash of the session token stored in the database -Do not block V2 development on complete moveset documentation. +Do not replace the existing session design with JWTs merely because JWTs are common. + +The current same-origin server-side session model is appropriate. --- -## Phase Moves +# 9. V3 Milestone 2 — User-Owned Attempts ✅ -Join table recording which moves appear in which phases. +Already completed. -Conceptual fields: +Current expectations: -```text -boss_phase_id -move_id -``` +- new attempts belong to the authenticated user +- ownerless historical attempts are preserved +- ownerless historical attempts are hidden +- explicit claiming is supported +- request bodies do not control ownership -Relationship: +Do not silently delete historical attempts. -```text -Phase - Many - ↕ -Many Moves -``` +--- + +# 10. V3 Milestone 3 — Per-User Analytics and Isolation ✅ -Example (Owl (Father)): +Already completed. -| boss_phase_id | move_id | meaning | -| ------------- | ------- | ------- | -| Phase 1 | Shadowfall | Shadowfall appears in Phase 1 | -| Phase 2 | Shadowfall | Shadowfall also appears in Phase 2 | -| Phase 2 | Owl Teleport | Owl Teleport only appears in Phase 2 | +The following must remain user-scoped: -Because a move is stored once, an attempt that failed to Shadowfall always references the same `failure_move_id`, regardless of the phase. Phase information comes from `phase_reached`. +- attempt history +- boss analytics +- progression +- recent-attempt comparisons +- Sekiro dashboard -The phase and the move must belong to the same boss. +Do not change the mathematical definitions of V2 analytics solely because user identity was added. --- -## Attempts +# 11. V3 Milestone 4 — Bilingual Interface ✅ -Conceptual fields: +Implement English / Chinese product support. -```text -id -boss_id -result -phase_reached -failure_move_id -failure_category -notes -created_at -``` +## User Preference -`id` is an integer primary key. The API exposes it as a string so the existing frontend `Attempt.id: string` contract does not change. +Logged-in users: -`failure_move_id` should be nullable. +```text +language preference +→ stored in user account +``` -Valid failure categories may include: +Visitors: ```text -known_move -other -not_sure +language preference +→ browser storage ``` -A victory should not require a failure move. +The initial visitor language may use browser language. --- -# Database Integrity - -Use relational constraints where they protect meaningful domain rules. - -Examples: +## Interface Translation -* boss must reference an existing game -* phase must reference an existing boss -* move must reference an existing boss -* phase_moves entries must reference an existing phase and an existing move -* attempt must reference an existing boss -* failure move must reference an existing move when present +Use a lightweight translation layer. -Application-level validation should still ensure that: +Example concept: -* the selected phase belongs to the selected boss -* the selected failure move belongs to the selected boss -* a phase_moves entry never links a phase and a move from different bosses -* invalid boss / phase / move combinations are rejected +```typescript +t("nav.dashboard") +t("auth.login") +t("attempt.record") +``` -Do not rely only on frontend validation. +For only English and Chinese, do not introduce a heavy localization framework unless the current simple approach becomes inadequate. --- -# SQLAlchemy Guidelines - -Use SQLAlchemy for database persistence. +## Boss Content -Prefer clear ORM models and explicit relationships. +Existing supported boss content should have both English and Chinese display forms where practical: -Avoid: +- boss name +- location +- phase names +- move name +- description +- telegraph +- counter +- common mistakes -* unnecessarily generic base repository frameworks -* excessive abstraction around simple CRUD -* dynamic query builders without a concrete need -* hidden database behavior +Do not claim a Chinese term is official unless the source supports that claim. -Database access should remain understandable to someone reading the code. +If a translation is authored manually, treat it as a translation. --- -# Alembic Guidelines - -Use Alembic for schema evolution. +## Language Rule -Do not manually mutate the production schema without migrations. +A page should normally display one language at a time. -Typical workflow: +Avoid mixed-language content such as: ```text -Modify SQLAlchemy model - ↓ -Generate / write Alembic migration - ↓ -Review migration - ↓ -Apply migration +Floating Passage 浮舟渡 ``` -Do not blindly trust autogenerated migrations. - -Review: - -* created tables -* dropped columns -* constraints -* foreign keys -* nullable changes -* indexes - -before applying them. +unless there is a deliberate product reason. --- -# V1 JSON Migration +## Delivered -Existing V1 data should not simply be discarded. +- language switch in the nav bar; `users.preferred_language` (set through `PATCH /api/auth/me`) for logged-in users, `localStorage` for visitors, and the browser language on a first visit +- every interface string in `frontend/src/i18n/messages.ts`; the Chinese table must have exactly the same keys as the English one, enforced by the TypeScript type +- Chinese boss names, locations, phase names, move names, descriptions, telegraphs, counters and common mistakes for the existing bosses +- the seed rejects an English text field without its Chinese version, and a move without a Chinese name +- `moves.name_zh_source` is `wiki` (with `name_zh_source_url`, checked by a database constraint) only when the full name appears on a Chinese wiki page; everything else is `translation` +- Chinese move descriptions are translations of the sourced English text; the Chinese moveset shows one note saying so +- move descriptions appear as a hover tooltip and under the move select when recording an attempt -Create an explicit migration / seed process. +The user reviewed all Chinese content. -Conceptual flow: +--- -```text -bosses.json -attempts.json - ↓ -Import Script - ↓ -Validate Data - ↓ -PostgreSQL -``` +# 12. V3 Milestone 5 — Existing-Content Cleanup ✅ -Suggested location: +This milestone has changed significantly from the old roadmap. -```text -backend/scripts/ -``` +## Old Requirement -Potential script: +The previous plan required: ```text -import_v1_data.py +all main bosses ++ +reviewed Wiki movesets ``` -The import process should: +That is no longer the V3 target. -1. load V1 boss data -2. validate the source records -3. create the game -4. create bosses -5. create phases -6. create moves -7. migrate existing attempt history -8. preserve relationships between attempts and moves -9. avoid creating duplicate seed data when rerun accidentally +--- -Do not permanently maintain two competing persistence implementations after migration is complete. +## New Requirement ---- +Review the content already present. -# V2 Boss Coverage +Goals: -V2 should expand from one representative boss to approximately **5–8 major Sekiro bosses**. +- fix obvious inaccuracies +- remove misleading descriptions +- preserve source links +- avoid placeholder content +- preserve phase relationships +- identify uncertain content +- do not invent missing moves -Potential set: +Do not bulk-add every remaining Sekiro boss from Wiki data solely to finish a checklist. -* Genichiro Ashina -* Lady Butterfly -* Guardian Ape -* Corrupted Monk -* Great Shinobi Owl -* Owl (Father) -* Isshin -* Demon of Hatred +--- -The exact list may change. +## Progress -The objective is NOT full Sekiro encyclopedia coverage. +The existing content was reviewed against both Fextralife and Fandom, and the user decided each item. Done on `dev`: -The objective is to validate that the architecture handles: +- fixed: True Corrupted Monk phase 2 keeps her phase-1 moves; Guardian Ape gains the phase-2 Jumping Sweep; misattributed or misleading text corrected; Owl (Father)'s Shadowfall named as the combat art 秘传·巨型忍者落杀 +- the Isshin fight starts with a Genichiro, Way of Tomoe phase (only the moves both wikis agree on); Isshin's own phases are now 2-4, and existing Isshin attempts were moved up one phase by a migration +- actions that cannot end an attempt were removed from the movesets (Owl's zig-zag, Guardian Ape's roar, Lady Butterfly's rafter jump, Great Shinobi Owl's Shinobi Charm) +- both Owls gained Mikiri Counter, from Fandom, using the new move-level source fields +- move types made consistent +- the cross-wiki mapping for every boss is in `research/ground_truth/semantic/wiki_mapping/`; it records what both wikis say, including what the product does not use -* different boss phase counts -* different movesets -* different attempt histories -* multiple boss dashboards -* game-level comparisons +Decided: Lady Butterfly and Guardian Ape keep their current move lists. Fandom's more specific lists are recorded in the mapping, and V4 rebuilds these movesets from engine evidence. --- -# Boss Data Strategy - -Do not spend excessive development time entering every possible boss move. +# 13. Wiki Data Policy -Prioritize: +Wiki sources are useful for: -1. representative bosses -2. major identifiable moves -3. correct phase relationships -4. useful practice information -5. source traceability +- move names +- human descriptions +- strategy descriptions +- community terminology +- discovery -Avoid creating hundreds of low-quality placeholder records. +Wiki sources are **not assumed to be complete ground truth**. ---- +If two sources disagree: -# Data Provenance +```text +do not silently pick one +``` -Each boss records the source of its phase and move data: +If the application cannot confidently resolve the disagreement: ```text -source_name -source_url +record uncertainty +or +defer to V4 research ``` -Source metadata is kept at the boss level because every move currently comes from its boss's page. +Do not manufacture a confident answer. -This is useful for: +Optional move fields: -* verifying move descriptions -* future data cleanup -* future structured ingestion -* avoiding invented mechanics +Only fill `telegraph` and `common_mistakes` when the source explicitly states them. -Do not present uncertain Sekiro mechanics as verified facts. +Leave them null rather than inferring them from the description or from general game knowledge. --- -# Existing V1 APIs +# 14. V3 Content Completeness Definition -The following capabilities should remain functional. +V3 requires: -## Health +> **product-level content quality** -```text -GET /health -``` +V3 does not require: ---- +> **engine-level combat completeness** -## Boss List +Product-level quality means: -```text -GET /api/bosses -``` +- the existing content is useful +- obviously wrong content is corrected +- major user-facing moves are represented +- uncertain information is not presented as fact + +Engine completeness belongs to V4. --- -## Boss Detail +# 15. V3 Milestone 6 — Hardening and Release ✅ -```text -GET /api/bosses/{boss_id} -``` +Before releasing V3: ---- +- backend tests pass +- frontend tests pass +- TypeScript typecheck passes +- lint passes +- frontend build passes +- frontend-backend integration tests pass +- Docker Compose build passes +- Docker smoke test passes +- authentication isolation remains covered +- bilingual behavior is tested +- data survives a Docker Compose restart (checked by the `docker` CI job) + +The application remains locally deployable through Docker Compose. -## Record Attempt +Release: ```text -POST /api/bosses/{boss_id}/attempts +v3.0.0 ``` --- -## Attempt History +# 16. V3 Non-Goals -```text -GET /api/bosses/{boss_id}/attempts -``` +Do not implement in V3 unless explicitly requested: + +- public cloud deployment +- production domain / HTTPS +- complete engine-derived movesets +- exhaustive character animation extraction +- full reverse engineering pipeline +- practice recommendation algorithms +- video upload +- computer vision +- automatic move detection +- automatic response detection +- gameplay telemetry +- true per-move success rates +- Black Myth: Wukong +- Redis without a demonstrated need +- Kafka without a demonstrated need +- Kubernetes solely for portfolio complexity +- microservices without a demonstrated need --- -## Boss Analytics +# 17. V3 Success Criteria + +V3 is complete when a user can: ```text -GET /api/bosses/{boss_id}/analytics +Open Application + ↓ +Choose English / 中文 + ↓ +Register / Log In + ↓ +Browse Existing Boss Content + ↓ +Record Attempts + ↓ +See Only Personal Attempts + ↓ +See Only Personal Analytics + ↓ +Restart Docker Stack + ↓ +Data Persists ``` -These endpoints should move from JSON-backed services to database-backed services without unnecessary frontend disruption. +V3 proves the product architecture. + +It does not claim complete combat knowledge. --- -# V2 Analytics Goal +# 18. V4 — Combat Ground Truth Foundation + +V4 answers: -V1 mainly provides aggregate failure analytics. +> **What boss actions actually exist, and what evidence supports the semantic moves used by the product?** -V2 should introduce meaningful progression analytics. +This version exists because the Wiki-only approach is not sufficiently reliable for later automated gameplay analysis. -The main questions are: +V4 is primarily: ```text -Am I reaching later phases more often? +runtime instrumentation ++ +reverse engineering ++ +data engineering ++ +semantic mapping +``` -Are earlier bottlenecks becoming less frequent? +Do not treat V4 primarily as a machine-learning milestone. -Which boss is currently giving me the most difficulty? +--- -How does recent performance compare with historical performance? -``` +# 19. V4 Evidence Model ---- +Ground truth should be based on several evidence layers. -# Boss-Level Analytics +Conceptually: -Boss dashboards should continue to support: +```text +Gameplay Observation + ↕ +Runtime Behavior/Event + ↕ +Engine Animation + ↕ +TAE / Attack / Projectile / Effect Data + ↕ +Semantic Move +``` -* total attempts -* defeated status -* best phase reached -* main bottleneck phase -* most common known failure move -* failure breakdown by phase -* failure breakdown by move +No single source should automatically override all others. -V2 should extend these with progression-oriented metrics. +**The project owner decides what is ground truth.** Claude and any tooling gather evidence, compare sources and propose, stating their uncertainty. They never mark a semantic move, an engine mapping or a phase structure as verified on their own. --- -# Attempt Progression +# 20. Rulesets -Track phase reached across attempts. +V4 must introduce the concept of a combat ruleset. -Example: +At minimum: ```text -Attempt 1 Phase 1 -Attempt 2 Phase 1 -Attempt 3 Phase 2 -Attempt 4 Phase 2 -Attempt 5 Phase 3 -Attempt 6 Phase 2 -Attempt 7 Phase 3 -Attempt 8 Victory +Vanilla Sekiro +Resurrection Mod ``` -The frontend may visualize this as a simple progression chart. +Version metadata should be preserved where possible. + +Do not combine Vanilla and Resurrection observations into one unlabeled dataset. + +The same semantic move may have different: + +- animations +- timing +- combo structure +- runtime events +- phase behavior -Avoid overly complicated visualization libraries if a simple implementation is sufficient. +across rulesets. --- -# Recent vs Historical Analytics +# 21. Ground Truth Research Must Stay Separate Initially -V2 should support a concept such as: +Do not immediately add unstable reverse-engineering concepts to production PostgreSQL. + +Begin with a research workspace. + +Recommended: ```text -All-Time -vs -Recent Attempts +research/ +└── ground_truth/ + ├── README.md + ├── rulesets/ + ├── characters/ + ├── engine/ + ├── semantic/ + ├── observations/ + ├── annotations/ + └── scripts/ ``` -Default recent window may be: +Example responsibilities: ```text -Last 10 attempts -``` +rulesets/ +→ Vanilla / Resurrection version metadata -unless a better product reason emerges. +characters/ +→ product boss ↔ character ID mappings -Example: +engine/ +→ animation / runtime / TAE metadata + +semantic/ +→ semantic move mappings -| Metric | All-Time | Last 10 | -| ---------------- | -------: | ------: | -| Phase 1 Failures | 10 | 1 | -| Phase 2 Failures | 15 | 4 | -| Phase 3 Failures | 7 | 5 | +observations/ +→ runtime gameplay observations -This helps identify changing bottlenecks. +annotations/ +→ video timestamp labels + +scripts/ +→ extraction / validation utilities +``` + +`research/ground_truth/` already exists. V3 Milestone 5 added `semantic/wiki_mapping/`: one JSON file per boss that lines up product moves with Fextralife and Fandom entries, plus `scripts/wiki_mapping_report.py`, which rebuilds the readable report and checks that every product move is mapped exactly once. Use it as the starting candidate list for the semantic catalog and as the baseline for measuring the gap to engine-level ground truth. --- -# Failure Trend +# 22. Copyright / Repository Rule -The application may compare failure counts over time. +Do not commit proprietary game assets to Git. -Example: +Do not commit: -```text -Floating Passage +- original game binaries +- extracted full game archives +- FromSoftware animation bundles +- proprietary model/texture files +- large copyrighted gameplay assets without a clear reason and permission -All-Time Failures: -12 +Prefer committing: -Last 10 Attempts: -2 -``` +- scripts +- hashes +- derived metadata +- mappings +- identifiers +- annotations +- documentation + +Raw extracted game assets should remain local and gitignored. -Acceptable interpretation: +--- -> Recorded failures attributed to Floating Passage are less common recently. +# 23. V4 Milestone 1 — Research Foundation -Do NOT automatically interpret this as: +Create: -```text -Floating Passage success rate increased to X%. -``` +- ruleset metadata +- research folder structure +- ground-truth schema drafts +- local asset paths through configuration +- `.gitignore` rules +- scripts for safe metadata extraction -The application does not know how many times the move occurred. +Do not modify the production DB merely to anticipate later needs. --- -# First Victory +# 24. V4 Milestone 2 — Genichiro Runtime Pilot + +Use Genichiro as the first vertical slice. -Where useful, track: +Start with Vanilla unless explicitly choosing Resurrection first for a specific experiment. + +Objectives: + +1. identify relevant character IDs +2. enable the available debug/developer instrumentation +3. record gameplay +4. observe runtime behavior/event/animation identifiers +5. align identifiers with visible attacks + +First prove: ```text -Attempts Until First Victory +visible action +→ runtime identifier ``` -This can be calculated from chronological attempt history. +for a small number of representative attacks. -Avoid storing derived values redundantly unless there is a performance reason. +Do not attempt full game extraction before proving this bridge works. --- -# Game-Level Sekiro Dashboard +# 25. V4 Runtime-First Principle -V2 should introduce an overall Sekiro analytics view. +Prefer: -This is one of the main product upgrades from V1. +```text +observe action in game + ↓ +capture runtime identifier + ↓ +search relevant files + ↓ +trace engine evidence +``` -Potential metrics: +over: ```text -Bosses Attempted -Bosses Defeated -Total Attempts -Most Practiced Boss -Boss Requiring Most Attempts -Recent Practice Activity +extract thousands of animations + ↓ +inspect everything manually + ↓ +guess which animation matches gameplay ``` -Example: +Runtime instrumentation should shrink the static reverse-engineering search space. -```text -Sekiro Practice Dashboard +--- -Bosses Attempted 6 -Bosses Defeated 4 -Total Attempts 143 +# 26. V4 Milestone 3 — Static Engine Mapping -Most Practiced Boss -Genichiro Ashina +After obtaining runtime identifiers, trace them into engine/mod data. -Boss Requiring Most Attempts -Owl (Father) +Possible chain: + +```text +runtime event + ↓ +HKS / behavior logic + ↓ +animation + ↓ +TAE + ↓ +AtkParam / Bullet / SpEffect ``` ---- +Use: -# Boss Comparison +- Debug Menu / developer runtime information +- Resurrection override files +- targeted original-game extraction +- DSAnimStudio or equivalent animation inspection +- TAE extraction +- parameter inspection -The Sekiro dashboard may include: +only as required. -| Boss | Attempts | Best Result | Defeated | -| ---------------- | -------: | ----------- | -------- | -| Genichiro Ashina | 24 | Victory | Yes | -| Guardian Ape | 18 | Victory | Yes | -| Owl (Father) | 31 | Phase 2 | No | -| Isshin | 14 | Phase 3 | No | +Do not introduce tooling simply because it exists. -Do not invent a universal boss difficulty ranking. +--- -Any "hardest boss" metric must be clearly defined from the player's own attempt data. +# 27. Resurrection Analysis Strategy -For example: +Treat Resurrection as: -```text -Boss Requiring Most Attempts -``` +> **a separate ruleset and a useful differential clue** -is more precise than: +The mod override directory shows which resources Resurrection replaces. + +Prefer: ```text -Hardest Boss +Mod Override First + ↓ +Trace Changed Resources + ↓ +Vanilla Extraction On Demand ``` -unless the definition is explicitly shown. +instead of extracting the entire vanilla game immediately. ---- +Later compare: -# Search and Filtering +```text +Vanilla + ↕ +Resurrection +``` -Not planned. Sekiro has roughly 20 bosses even including mini-bosses, so every boss fits on one page, and the Sekiro Dashboard's boss comparison table already shows each boss's attempts and defeat status. See Milestone 8. +at the levels of: -Revisit only if the boss list grows well beyond what fits on one page. +- runtime behavior +- combo transitions +- animation sequence +- TAE timing +- attack/projectile/effect parameters --- -# Frontend Structure +# 28. Engine Action vs. Semantic Move -The current frontend structure should remain simple and explicit. - -Preferred conceptual structure: +Do not force: ```text -frontend/src/ -├── api/ -├── components/ -├── pages/ -├── types/ -└── App.tsx +1 Engine Animation += +1 Semantic Move ``` -V2 may introduce an additional page: +A semantic move may contain: ```text -Sekiro Dashboard -Boss Selection / Browse -Boss Dashboard +Animation A +Animation B +Animation C ``` -Avoid turning every dashboard section into a separate route unless the UX benefits. - ---- - -# Boss Dashboard Priority +One low-level action may also be reused in several contexts. -The boss dashboard should prioritize: +Expect the eventual relationship to possibly be: ```text -Boss Summary -↓ -Record Attempt -↓ -Progress Analytics -↓ -Recent Attempts -↓ -Boss Moveset Reference +SemanticMove + ↕ +many-to-many + ↕ +EngineAction ``` -This remains an analytics application. - -It should not become primarily a Wiki. +Preserve this uncertainty until actual data confirms the schema. --- -# Frontend Type Safety +# 29. V4 Milestone 4 — Genichiro Semantic Catalog -Continue using explicit TypeScript domain types. +For each semantic move, gather evidence such as: -Examples: +- ruleset +- character ID +- phase +- runtime events +- animation IDs +- TAE evidence +- parameter references +- gameplay observations +- video examples +- confidence -```text -BossSummary -Boss -BossPhase -BossMove -Attempt -BossAnalytics -SekiroAnalytics -ProgressionPoint +Example conceptual record: + +```json +{ + "move_slug": "floating-passage", + "ruleset": "vanilla", + "phases": [1, 2], + "runtime_events": [], + "animation_ids": [], + "observed_clip_count": 0, + "tae_verified": false, + "confidence": "unknown" +} ``` -Avoid `any`. +This is an illustrative schema only. -Keep frontend types synchronized with backend response models. +Do not lock production architecture to it prematurely. --- -# API Client +# 30. Confidence -Continue centralizing backend calls. +Ground-truth mappings should support an evidence/confidence concept. -Prefer: +Possible values: ```text -frontend/src/api/ +high +medium +low +unknown ``` -rather than raw fetch calls inside many components. +Confidence should be based on evidence, not intuition. -Potential V2 functions: +For example: ```text -getBosses() -getBossById() -createAttempt() -getBossAttempts() -getBossAnalytics() -getSekiroAnalytics() +high: +runtime observed ++ +animation matched ++ +TAE / attack evidence ++ +multiple gameplay examples ``` +Do not automatically treat a Wiki description as `high`. + +Confidence values are proposals until the project owner confirms them. + --- -# Backend Structure +# 31. V4 Milestone 5 — Completeness Review -A reasonable V2 backend structure is: +For each supported boss define: ```text -backend/ -└── app/ - ├── main.py - │ - ├── models/ - │ ├── db/ - │ └── schemas/ - │ - ├── routers/ - │ ├── bosses.py - │ ├── attempts.py - │ └── analytics.py - │ - ├── services/ - │ ├── boss_service.py - │ ├── attempt_service.py - │ └── analytics_service.py - │ - ├── db/ - │ ├── session.py - │ └── base.py - │ - └── ... +E = engine combat candidates +R = runtime-observed actions +S = semantic moves ``` -Exact folder names may differ. - -Do not reorganize the entire repository merely to match this example if the existing layout is already clear. +Classify every engine candidate as: ---- +```text +mapped_to_semantic_move +non_combat +locomotion +hit_reaction +cinematic_or_scripted +duplicate_or_shared +unused_or_unreachable +unknown +``` -# Service Layer +Investigate unexplained combat candidates. -Prefer: +The goal is: ```text -Router - ↓ -Service - ↓ -Database +unknown combat-relevant actions +→ as close to zero as practical ``` -Route handlers should primarily: - -1. receive input -2. validate request parameters -3. call application logic -4. return response models - -Analytics calculations should remain outside route handlers. +Do not claim absolute mathematical completeness unless evidence supports it. --- -# Database Session Management +# 32. V4 Milestone 6 — Resurrection Ground Truth -Database session lifecycle should be explicit and safe. +After the Vanilla pipeline is understood, build the Resurrection mapping. -Avoid: +Classify differences as: + +```text +unchanged +modified +new_in_resurrection +vanilla_only +same_semantic_move_different_engine_sequence +``` -* long-lived global sessions -* silently swallowed transaction failures -* manual transaction handling scattered across route handlers +Every gameplay/video observation must carry its ruleset. -Use clear FastAPI dependency patterns or equivalent clean session management. +This becomes essential for future CV training. --- -# Query Efficiency +# 33. V4 Milestone 7 — Product Integration -V2 remains a small application. +Only after the research schema stabilizes: -Do not prematurely optimize database queries. +- correct inaccurate production moves +- improve phase mappings +- sync verified semantic content +- preserve attempt foreign-key integrity +- consider adding ruleset support +- consider adding engine-action mappings -However, avoid obvious issues such as: +Potential future tables: -* loading the entire attempts table when only one boss is needed -* N+1 queries caused by careless relationship loading -* repeated identical queries inside loops +```text +rulesets +boss_variants +engine_actions +move_engine_actions +``` -Only introduce indexing or query optimization when the access pattern justifies it. +These are not mandatory names. + +Let the actual research data determine the relational model. --- -# V2 Testing Strategy +# 34. V4 Milestone 8 — Boss Expansion -Testing should focus on product behavior and database correctness. +After Genichiro is complete enough to validate the pipeline, expand boss-by-boss. ---- +Do not immediately process every boss in parallel. -## Existing API Behavior - -Continue testing: +Prefer representative progression: ```text -GET /health -GET /api/bosses -GET /api/bosses/{boss_id} -POST /api/bosses/{boss_id}/attempts -GET /api/bosses/{boss_id}/attempts -GET /api/bosses/{boss_id}/analytics +Genichiro +↓ +another humanoid boss +↓ +beast boss +↓ +projectile/special-effect-heavy boss +↓ +late-game complex boss ``` ---- +Reuse tooling aggressively. -## Database Tests +The ground-truth pipeline should become more automated with each boss. -Add tests for: +--- -* boss persistence -* phase relationships -* move relationships -* attempt persistence -* nullable failure moves -* invalid boss references -* invalid phase / move combinations +# 35. V4 Milestone 9 — Public Deployment ---- +Public deployment is intentionally deferred until V4. -## Migration Tests +Before deployment ensure: -Verify that: +- authentication works reliably +- user isolation is tested +- bilingual UI works +- database migrations are reproducible +- verified content is distinguishable from uncertain content +- at least one ground-truth vertical slice is integrated +- `attempts.user_id` is made NOT NULL through a migration. V3 left it nullable so attempts recorded before accounts existed could stay ownerless until claimed with `python -m scripts.claim_attempts `. The migration should fail loudly if any ownerless attempts remain. -```text -Alembic upgrade -``` +Production concerns may include: -can create the expected schema from a clean database. +- HTTPS +- production PostgreSQL +- secure environment variables +- database backups +- migration execution +- secure cookies +- deployment workflow +- logging +- recovery -Where practical, test V1 data import independently. +Choose infrastructure based on actual hosting requirements. + +Do not introduce Kubernetes by default. --- -## Analytics Tests +# 36. V4 Release Boundary -Analytics tests should use controlled datasets. +Do not block `v4.0.0` on ground-truth completeness for every Sekiro boss. -Example: +Recommended `v4.0.0` target: ```text -Attempt 1 → Phase 1 → Thrust -Attempt 2 → Phase 2 → Floating Passage -Attempt 3 → Phase 2 → Floating Passage -Attempt 4 → Phase 3 → Lightning +Genichiro ground-truth pipeline proven ++ +verified production integration ++ +stable public deployment ``` -Expected: +Then expand boss coverage through: ```text -main bottleneck = Phase 2 -most common failure move = Floating Passage +v4.1.x +v4.2.x +... ``` -Progression logic should also be tested with deterministic histories. +This avoids turning “all bosses complete” into an unbounded release blocker. --- -# Integration Testing +# 37. V5 — Automated Gameplay Analysis -V2 should contain at least one meaningful end-to-end backend integration workflow. +V5 answers: -Example: +> **What happened during the fight without requiring the player to manually enter everything?** + +The desired long-term flow is: ```text -Create Boss Data +Upload Gameplay + ↓ +Detect Boss / Ruleset ↓ -Create Attempt +Detect Phase ↓ -Persist to PostgreSQL +Detect Move Occurrences ↓ -Read Attempt History +Detect Player Responses ↓ -Request Analytics +Compute Analytics ↓ -Verify Updated Metrics +Recommend Practice ``` -This is more valuable than testing implementation details individually. - --- -# CI - -GitHub Actions should validate both backend and frontend. - -Conceptually: - -```text -Pull Request / Push - ↓ -Backend Checks -├── install dependencies -├── pytest -└── database tests - -Frontend Checks -├── npm ci -├── typecheck -├── lint -└── build -``` - -Only include commands that actually exist in the project. +# 38. V5 Development Order -Do not invent package scripts solely because they are listed here. +Do not jump directly to full automatic fight understanding. ---- - -# V2 CI / Deployment - -V2 has continuous integration but no continuous deployment. +Use: ```text -feature/* - ↓ -Pull Request - ↓ -CI (backend, frontend, integration, docker) - ↓ -dev - ↓ -main - ↓ -v2.0.0 (run locally with Docker Compose) +1. Video upload +2. Manual annotation +3. Semi-automatic annotation +4. Short-clip move classification +5. Temporal move detection +6. Player-response detection +7. Automatic fight report +8. Recommendations ``` -`dev` and `main` are protected: all four CI jobs must pass before a pull request can merge. +Every stage should produce useful intermediate tooling. --- -# Public Deployment (Deferred Until After V3) +# 39. V5 Video Data Model Principles -V2 runs locally only. A stable public URL is deferred until after V3, because: +PostgreSQL should hold: -* 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 +- metadata +- user ownership +- analysis records +- annotations +- processing status +- references -For temporary remote access, such as from a phone away from home, a Cloudflare Tunnel to the local instance is acceptable. +Large video blobs should generally use appropriate file/object storage rather than database byte columns. -When public deployment is picked up, the provider-independent work is: - -* a production compose configuration that does not publish the PostgreSQL or backend ports -* HTTPS -* scheduled `pg_dump` backups of attempt data -* deployment from `main` through GitHub Actions -* setup documentation +Do not choose a storage provider until public deployment requirements justify it. --- -# Docker +# 40. V5 Analytics Rule -Continue supporting Docker. +True per-move success rates become valid only when move occurrences and responses are actually observed. -V2 Docker setup should account for: +Before that: ```text -frontend -backend -postgres +failure count ``` -Local development may use: +is valid. + +After reliable gameplay observation: ```text -docker compose +success / occurrence count ``` -Do not introduce Kubernetes simply to orchestrate three local services. +may become valid. + +Do not mix the two definitions. --- -# Environment Configuration +# 41. Practice Recommendations Move to V5 -Database URLs and deployment-specific configuration should use environment variables. +Do not build a sophisticated recommendation engine from incomplete manual failure labels. -Do not commit: +Recommendations become significantly more valuable when based on observed move occurrences. -* passwords -* production credentials -* secret keys -* private database URLs +They should remain explainable. -Provide an example environment file where useful: +Example: ```text -.env.example -``` +Floating Passage occurred 8 times. +You handled 2 cleanly. +You were hit or failed 6 times. -Do not commit real `.env` secrets. - ---- - -# V2 Milestones - -Implement V2 incrementally. +Suggested focus: +Floating Passage. +``` -Each milestone should leave the project in a working state. +Avoid opaque skill scores. --- -## Milestone 1 — PostgreSQL Foundation (Completed) +# 42. V6 — Multi-Game Platform -### Goal +V6 generalizes the architecture beyond Sekiro. -Replace JSON persistence with a relational persistence foundation. - -### Tasks - -* add PostgreSQL development environment -* add SQLAlchemy -* add Alembic -* configure database connection -* configure database session handling -* define initial relational schema -* create first Alembic migration -* verify schema can be created from scratch -* run database tests against a PostgreSQL service in backend CI - -### Completion Criteria +The first planned additional game is: ```text -FastAPI - ↓ -SQLAlchemy - ↓ -PostgreSQL +Black Myth: Wukong ``` -is working locally. +Do not begin V6 abstractions during V3–V5 unless a real shared requirement emerges. -Existing APIs do not yet need every V2 feature, but database infrastructure must be stable. +Avoid speculative generic schemas. --- -## Milestone 2 — V1 Data Migration (Completed) - -### Goal - -Move existing boss and attempt data into PostgreSQL. - -### Tasks +# 43. Backend Engineering Rules -* create V1 JSON import script -* migrate Genichiro -* migrate phases -* migrate moves -* migrate attempt history -* validate relationships -* preserve existing API behavior -* remove application dependency on JSON persistence once migration is validated +Use: -### Completion Criteria +- FastAPI +- Pydantic +- SQLAlchemy +- Alembic +- PostgreSQL -The existing V1 workflow works against PostgreSQL: +Keep: ```text -Choose Genichiro - ↓ -View Dashboard - ↓ -Record Attempt - ↓ -PostgreSQL - ↓ -History + Analytics +router +→ service +→ database ``` -The frontend should require minimal or no persistence-specific changes. - ---- - -## Milestone 3 — Multi-Boss Support (Completed) - -### Goal - -Validate the relational model across several different Sekiro bosses. +separation. -### Tasks +Do not put complex queries directly in route handlers. -* add approximately 5–8 major bosses -* add phase structures -* add representative moves -* verify attempt creation for every supported boss -* verify analytics remain boss-specific -* eliminate any Genichiro-specific application logic - -### Completion Criteria - -The same code path supports multiple bosses without special-case logic. +Use database constraints where domain invariants clearly belong in the database. --- -## Milestone 4 — Richer Boss Metadata (Completed) +# 44. Alembic Rules -### Goal +Every production schema change must have a reviewed Alembic migration. -Improve the moveset reference enough to support better failure identification. +Do not rely on: + +```python +Base.metadata.create_all(...) +``` -### Potential Fields +as a production migration strategy. -* move type -* description -* telegraph -* counter -* common mistakes -* source name -* source URL +Check migration/model drift in tests. -### Completion Criteria +Do not edit already-released migrations unless there is a compelling repository-specific reason. -Boss moves contain useful practice context without turning the application into a Wiki clone. +Prefer a new migration. --- -## Milestone 5 — Progression Analytics (Completed) +# 45. Seed Data Rules -### Goal +Boss reference data may continue to be maintained in source-controlled seed files. -Answer: +Runtime product reads should come from PostgreSQL. -> Am I improving over time? +Seed sync must not silently delete rows that existing attempts reference. -### Tasks +If a semantic move needs to be removed: -* model chronological attempt progression -* expose phase progression data -* implement recent-attempt window -* compare recent vs all-time failure patterns -* calculate attempts until first victory -* add relevant backend tests +1. check whether attempts reference it +2. decide how those attempts should be preserved +3. migrate deliberately +4. only then remove or hide the move -### Completion Criteria +Sources are recorded per boss (`bosses.source_name`, `bosses.source_url`). When a move's data comes from a different page, set that move's own `source_name` and `source_url`; leave them null otherwise. The frontend shows a move-level source under the move. -A boss dashboard can show meaningful change over time, not only lifetime totals. +When the product's moves change, update `research/ground_truth/semantic/wiki_mapping/` and run `python research/ground_truth/scripts/wiki_mapping_report.py`. --- -## Milestone 6 — Progression UI (Completed) +# 46. API Compatibility -### Goal +Preserve existing API contracts where practical. -Make the new analytics understandable visually. +If a contract changes: -### Tasks +- update backend schema +- update frontend types +- update API client +- update tests +- update integration tests -* add attempt progression visualization -* add all-time vs recent comparison -* display recent failure patterns -* retain existing V1 metrics -* preserve mobile usability - -### Completion Criteria - -A user should be able to inspect a boss page and quickly understand whether recent attempts differ from older ones. +Do not create hidden frontend/backend contract drift. --- -## Milestone 7 — Overall Sekiro Dashboard (Completed) - -### Goal +# 47. Frontend Rules -Move from boss-specific analytics to game-level analytics. - -### Tasks - -Add metrics such as: +Keep frontend structure clear: ```text -Bosses Attempted -Bosses Defeated -Total Attempts -Most Practiced Boss -Boss Requiring Most Attempts -Recent Practice Activity +api/ +components/ +pages/ +types/ +utils/ ``` -Add a boss comparison view. +Prefer explicit TypeScript types for backend responses. -### Completion Criteria +Keep persistence knowledge out of React components. -The user can understand their overall Sekiro practice history without opening every boss individually. +Do not let UI components know about SQLAlchemy or database tables. --- -## Milestone 8 — Search and Filtering (Skipped) - -### Decision - -Skipped. Sekiro has roughly 20 bosses even including mini-bosses, so the full list fits on one page and search adds little. The Sekiro Dashboard's boss comparison table (Milestone 7) already shows each boss's attempts and defeat status. +# 48. Testing Rules -This does not affect the V2 success criteria, which never required search or filtering. +Maintain coverage at several layers. -Do not add search or filtering unless the boss list grows well beyond what fits on one page. +## Backend ---- +- unit tests +- service tests +- API tests +- PostgreSQL-backed tests +- migration tests +- user-isolation tests -## Milestone 9 — Integration Testing and CI Hardening (Completed) +## Frontend -### Goal +- component tests +- typecheck +- lint +- build -Protect the full PostgreSQL-backed workflow. +## Integration -### Tasks +Test real frontend/backend contracts against PostgreSQL. -* database-backed API tests -* analytics tests -* migration tests -* one complete attempt integration test -* backend CI -* frontend CI -* verify Docker build in CI if useful +Important flows include: -### Completion Criteria +```text +register +→ login +→ record attempt +→ read attempt +→ analytics update +``` -A broken persistence or analytics change should normally be detected before merging into `dev`. +Future V4/V5 tests should be added incrementally as those systems exist. --- -## Milestone 10 — Stable Deployment (Deferred Until After V3) +# 49. CI Rules -### Decision +Current required checks should remain: -Public deployment is deferred until after V3. See "Public Deployment (Deferred Until After V3)" for the reasons and the work involved. - -### What V2 Still Includes +```text +backend +frontend +integration +docker +``` -* running the full stack locally with `docker compose up` -* local setup instructions in the README -* LAN access from other devices, such as a phone on the same network +Do not add CI jobs solely to increase complexity. -The V2 success criteria do not require public hosting. +A CI job should protect a real failure mode. --- -# V2 Development Order +# 50. Docker Rules -Recommended sequence: +Docker Compose remains the canonical local full-stack environment. -```text -1. PostgreSQL Foundation - -2. SQLAlchemy Models +A developer should be able to bring up: -3. Alembic Migration +```text +frontend +backend +postgres +``` -4. V1 JSON → PostgreSQL Migration +with documented environment variables. -5. Verify Existing V1 APIs +Do not make public-cloud infrastructure necessary for local development. -6. Multi-Boss Support +--- -7. Richer Boss Metadata +# 51. Branch Workflow -8. Progression Analytics Backend +Continue: -9. Progression Analytics Frontend +```text +main +↑ +dev +↑ +feature/* +``` -10. Overall Sekiro Dashboard +Meaning: -11. Search / Filter (skipped, see Milestone 8) +```text +main += released/stable -12. Integration Tests +dev += current integration branch -13. CI Hardening +feature/* += focused implementation branches +``` -14. Stable Deployment (deferred until after V3, see Milestone 10) +Ground-truth experiments may use focused branches such as: -15. Release v2.0.0 +```text +feature/ground-truth-genichiro +feature/bilingual +feature/content-cleanup ``` -Do not start several major milestones simultaneously unless there is a clear dependency reason. +Do not use a `stg` branch merely to represent a deployment environment. --- -# V2 Success Criteria +# 52. Scope Discipline -V2 is complete when a user can: +Do not add technology because it sounds production-grade. + +Before adding a technology, answer: ```text -Open Sekiro Dashboard - ↓ -View Multiple Bosses - ↓ -See Overall Sekiro Progress - ↓ -Choose a Boss - ↓ -View Attempt History - ↓ -View Progression Analytics - ↓ -Compare Recent vs Historical Performance - ↓ -Record New Attempt - ↓ -Persist to PostgreSQL - ↓ -Analytics Update +What current problem does this solve? ``` -and the system can support multiple bosses through the same architecture. +If there is no concrete answer, do not add it. + +Examples currently not justified by default: + +- Redis +- Kafka +- RabbitMQ +- Kubernetes +- service mesh +- microservices +- distributed tracing stack +- vector database +- LLM agent framework --- -# Release Boundary +# 53. Current Priority Order -Once the V2 success criteria are complete: +V3 is released. Work through V4 in milestone order: ```text -dev - ↓ -main - ↓ -v2.0.0 +1. Research foundation (V4 Milestone 1) +2. Genichiro runtime pilot (V4 Milestone 2) +3. Static engine mapping (V4 Milestone 3) +4. Genichiro semantic catalog (V4 Milestone 4) +5. Completeness review (V4 Milestone 5) ``` -Stop V2 development. +The later V4 milestones (Resurrection, product integration, boss expansion, public deployment) follow once Genichiro's pipeline is proven. -Do not delay the V2 release by introducing V3 features. +Keep the released V3 product working: fix product bugs on `dev` as they appear, and do not change the production schema for ground-truth research until V4 Milestone 7. --- -# Future Versions - -## V3 — Personalized Practice Coach +# 54. When Content Is Uncertain -Core question: +If asked to add or describe a move and the evidence is weak: -> What should I practice next? +```text +STOP +``` -Potential additions: +Then: -* 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) +1. identify the uncertainty +2. inspect available sources +3. compare runtime evidence if available +4. defer to V4 research if unresolved -These are NOT V2 requirements. +Never fabricate combat behavior to complete a data file. --- -## V4 — Gameplay Analysis +# 55. Current Definition of Success -Core question: +The project should optimize for: -> What actually happened during the fight? +```text +correctness ++ +traceability ++ +useful product behavior +``` -Potential additions: +not: -* gameplay video upload -* move occurrence annotation -* player response annotation -* computer vision -* automatic move detection -* true per-move success rates +```text +maximum number of features ++ +maximum number of technologies ++ +maximum number of boss rows +``` -These are NOT V2 requirements. +A smaller verified dataset is preferable to a larger unreliable one. --- -## V5 — Multi-Game Analytics - -Core question: - -> Can the same analysis work across games? - -Potential additions: - -* Black Myth: Wukong -* shared multi-game domain model -* game-specific combat mechanics -* cross-game analytics - -These are NOT V2 requirements. +# 56. Long-Term Product Direction ---- +The final product goal is: -# Important Instructions for Claude +> **A player uploads gameplay and receives trustworthy, explainable boss-practice analytics with minimal manual input.** -When working in this repository: +The project should reach this incrementally: -1. Treat V1 as completed. -2. Treat V2 as the current active development scope. -3. Preserve the lightweight manual attempt-recording workflow. -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. -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. -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. +```text +V1 +Manual attempt capture ---- +V2 +Structured analytics -# Current Milestone Rule +V3 +Multi-user product foundation -Before implementing a new feature, identify which V2 milestone it belongs to. +V4 +Combat ground truth -If the feature does not clearly belong to: +V5 +Automated gameplay analysis -```text -PostgreSQL Migration -Multi-Boss Support -Richer Boss Data -Progression Analytics -Sekiro Dashboard -Testing / CI -Local Docker Setup +V6 +Multi-game platform ``` -check whether it actually belongs to V3, V4, or V5 before adding it. - -The goal of V2 is not to maximize feature count. +Every major version should leave the application in a coherent, working state. -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 +Do not sacrifice a stable earlier layer merely to reach a more ambitious later feature. \ No newline at end of file diff --git a/README.md b/README.md index 8213a6e..0538a73 100644 --- a/README.md +++ b/README.md @@ -181,61 +181,46 @@ 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** was deferred: without accounts, a public instance would let anyone record attempts. V3 adds accounts, and public hosting is now planned for V4, once boss content is backed by verified game data. * **Boss search and filtering** was skipped, because 8 bosses fit comfortably on one page. --- +## V3 — Multi-User Product Foundation + +### V3.0 — Completed + +V3 turned the single-user local app into a foundation that several players can use, each with their own data. + +* ✅ **Accounts and authentication:** registration, login and logout, argon2-hashed passwords, and server-side sessions in an httpOnly cookie. Boss data stays browsable without an account. +* ✅ **User-owned attempts:** every attempt belongs to the player who recorded it. Attempts recorded before accounts existed are kept and can be claimed with `scripts.claim_attempts`. +* ✅ **Per-user analytics:** attempt history, boss analytics, progression, and the Sekiro dashboard only cover your own attempts. +* ✅ **English / 中文 interface:** a language switch, with each language showing only its own text. The choice is saved on the account, or in the browser for visitors. Most Chinese move names and descriptions are translations of the English source material. +* ✅ **Existing content cleanup:** the current boss data was checked against two wikis (Fextralife and Fandom); errors and misleading text were fixed and phase structures corrected, rather than adding more bosses from wikis. The comparison for every boss is kept in [`research/ground_truth/semantic/wiki_mapping/`](research/ground_truth/semantic/wiki_mapping/README.md) as the starting point for V4. +* ✅ **Hardening:** the four required CI jobs also check that accounts and attempts survive a Docker restart. + +While expanding boss coverage, community wiki movesets turned out to be incomplete and sometimes inconsistent with each other. V3 therefore treats boss data as human-readable reference content, not as a complete record of every attack in the game. Verifying movesets against the game itself is the job of V4. + +--- + ## Future Direction -The longer-term roadmap is: +The full plan is in [ROADMAP.md](ROADMAP.md). In short: ```text -V1 -Manual boss-attempt tracking -+ -basic failure analytics - - ↓ - -V2 -PostgreSQL -+ -expanded Sekiro boss data -+ -richer progression analytics - - ↓ - -V3 -User profiles -+ -long-term personalized practice tracking -+ -practice recommendations -+ -public deployment - - ↓ - -V4 -Gameplay video analysis -+ -automatic / semi-automatic move detection -+ -detailed combat performance metrics - - ↓ - -V5 -Multi-game platform -+ -Sekiro -+ -Black Myth: Wukong +V1 What killed me? → manual attempt analytics +V2 Where am I improving or struggling? → structured relational analytics +V3 Can different players use it? → multi-user product foundation +V4 What moves actually exist? → combat ground truth + public deployment +V5 What happened in the fight? → gameplay video analysis +V6 Can this work across games? → multi-game platform ``` -The long-term goal is to evolve Sekiro Boss Practice Analytics from a manual attempt tracker into a generalized boss-practice and gameplay-performance analytics platform. +* **V4** builds verified boss data from the game itself, through runtime instrumentation and targeted reverse engineering, starting with Genichiro. The app is then deployed publicly. +* **V5** analyzes uploaded gameplay to detect boss moves and player responses. Knowing how often each move actually occurred makes true per-move success rates and practice recommendations possible. +* **V6** extends the platform beyond Sekiro, starting with Black Myth: Wukong. + +The long-term goal is to turn gameplay footage into trustworthy, explainable boss-practice analytics with as little manual input as possible. --- @@ -260,6 +245,13 @@ V1 intentionally uses JSON persistence because the initial dataset is small and * **Deployment:** Docker Compose (local) * **CI:** GitHub Actions +### V3 + +Everything in V2, plus: + +* **Authentication:** argon2 password hashing, server-side sessions in an httpOnly cookie +* **Interface languages:** English and Chinese, with a small in-house translation dictionary + --- ## Running Locally @@ -282,10 +274,18 @@ 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. Switch between English and 中文 in the top-right corner. 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. +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 06f6c52..ec72c2d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -181,61 +181,46 @@ V2 使用 Docker Compose 在本地运行。 #### 推迟或跳过的内容 -* **公网部署**推迟到 V3 之后:目前只有一个用户,而且在没有账号系统的情况下,公开的实例会让任何人都能新增攻略记录。V3 会加入账号系统,这个问题也就随之解决。 +* **公网部署**被推迟了:在没有账号系统的情况下,公开的实例会让任何人都能新增攻略记录。V3 加入了账号系统;公网部署现在计划放在 V4,等 Boss 数据经过游戏本身的验证之后再进行。 * **Boss 搜索和筛选**被跳过了,因为 8 个 Boss 在一个页面上就能放得下。 --- +## V3 — 多用户产品基础 + +### V3.0 — 已完成 + +V3 把单用户的本地应用,变成可以供多名玩家使用、每人数据互相独立的产品基础。 + +* ✅ **账号与认证:** 注册、登录、登出;密码用 argon2 哈希保存;登录状态是存在 httpOnly cookie 里的服务器端会话。不登录也能浏览 Boss 资料。 +* ✅ **攻略记录归属用户:** 每条记录都属于记录它的玩家。有账号系统之前的旧记录都保留着,可以用 `scripts.claim_attempts` 认领。 +* ✅ **按用户的分析:** 攻略历史、Boss 分析、进度图和只狼总览都只统计你自己的记录。 +* ✅ **中英文界面:** 可以切换语言,每种语言只显示该语言的文字。登录用户的选择保存在账号里,访客的选择保存在浏览器里。大部分中文招式名和说明译自英文资料。 +* ✅ **清理现有内容:** 对照两个 Wiki(Fextralife 和 Fandom)检查了现有的 Boss 数据,修正了错误和有误导性的描述,也修正了阶段结构,没有继续从 Wiki 补充更多 Boss。每个 Boss 的对照结果保存在 [`research/ground_truth/semantic/wiki_mapping/`](research/ground_truth/semantic/wiki_mapping/README.md),作为 V4 的起点。 +* ✅ **加固:** 4 个必须通过的 CI 任务还会检查重启 Docker 后账号和攻略记录是否仍然保留。 + +在扩充 Boss 数据的过程中,我们发现社区 Wiki 的招式资料并不完整,不同 Wiki 之间有时还互相矛盾。所以 V3 把 Boss 数据定位为方便阅读的参考资料,而不是游戏里全部攻击的完整记录。用游戏本身验证招式数据,是 V4 的工作。 + +--- + ## 未来方向 -长期路线图如下: +完整计划见 [ROADMAP.zh-CN.md](ROADMAP.zh-CN.md)([English](ROADMAP.md))。简要来说: ```text -V1 -手动记录 Boss 尝试 -+ -基础失败分析 - - ↓ - -V2 -PostgreSQL -+ -扩充的只狼 Boss 数据 -+ -更丰富的进度分析 - - ↓ - -V3 -用户档案 -+ -长期的个性化练习追踪 -+ -练习建议 -+ -公网部署 - - ↓ - -V4 -实战录像分析 -+ -自动 / 半自动招式识别 -+ -详细的战斗表现指标 - - ↓ - -V5 -多游戏平台 -+ -只狼 -+ -黑神话:悟空 +V1 是什么打败了我? → 手动记录的尝试分析 +V2 我在哪里进步、在哪里卡住? → 结构化的关系型分析 +V3 不同玩家都能用吗? → 多用户产品基础 +V4 到底有哪些招式? → 战斗真实数据 + 公网部署 +V5 这场战斗里发生了什么? → 实战录像分析 +V6 能用于其他游戏吗? → 多游戏平台 ``` -长期目标是把 Sekiro Boss Practice Analytics 从一个手动尝试记录工具,发展成一个通用的 Boss 练习与实战表现分析平台。 +* **V4** 通过运行时监测和有针对性的逆向工程,从游戏本身建立经过验证的 Boss 数据,先从苇名弦一郎开始;之后再进行公网部署。 +* **V5** 分析上传的实战录像,识别 Boss 招式和玩家的应对。知道每个招式实际出现了多少次,才能算出真正的招式成功率,并给出练习建议。 +* **V6** 把平台扩展到只狼以外的游戏,第一个是黑神话:悟空。 + +长期目标是:把实战录像变成可信、可解释的 Boss 练习分析,并尽量减少玩家的手动输入。 --- @@ -260,6 +245,13 @@ V1 刻意使用 JSON 持久化,因为初始数据集很小,主要目标是 * **部署:** Docker Compose(本地) * **CI:** GitHub Actions +### V3 + +在 V2 的基础上增加: + +* **认证:** argon2 密码哈希,存在 httpOnly cookie 里的服务器端会话 +* **界面语言:** 中文和英文,使用自己实现的轻量翻译表 + --- ## 本地运行 @@ -282,10 +274,18 @@ V1 刻意使用 JSON 持久化,因为初始数据集很小,主要目标是 后端每次启动时都会执行数据库迁移,并从 `backend/seed/bosses.json` 同步 Boss 数据。 -3. 打开 http://localhost:8080。API 文档在 http://localhost:8000/docs。 +3. 打开 http://localhost:8080 并注册一个账号。不登录也可以浏览 Boss 的招式,但记录尝试和查看分析需要登录。右上角可以切换 English / 中文。API 文档在 http://localhost:8000/docs。 在同一网络下用手机访问时,打开 `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/ROADMAP.md b/ROADMAP.md index 1bb7006..52526da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,249 +1,1190 @@ # Sekiro Boss Practice Analytics — Roadmap +English | [简体中文](ROADMAP.zh-CN.md) + This roadmap defines the planned evolution of **Sekiro Boss Practice Analytics**. -Each version should introduce one clear new user capability without prematurely adding complexity from later stages. +The project began as a lightweight manual boss-attempt tracker, but development exposed an important constraint: + +> Wiki-level boss movesets are useful for human-readable product content, but they are not reliable enough to serve as complete ground truth for gameplay analysis. + +Some boss moves are missing, grouped inconsistently, or described inaccurately across community sources. Because later gameplay-analysis features depend on a trustworthy move taxonomy, engine/runtime ground truth must be established before automated video analysis. + +The roadmap therefore separates: + +```text +Product foundation + ↓ +Combat ground truth + ↓ +Automated gameplay understanding + ↓ +Multi-game generalization +``` + +Each version should answer one clear technical and product question. + +--- + +# V1 — Manual Boss Attempt Analytics ✅ + +## Core Question + +> **What killed me?** + +V1 established the smallest complete product workflow. + +Players manually record information they can realistically remember after a boss attempt: + +- result: victory or failure +- phase reached +- failure move, `Other`, or `Not Sure` +- optional notes + +The application provides: + +- boss selection +- boss dashboard +- attempt recording +- attempt history +- total attempts +- best phase reached +- defeated status +- main bottleneck phase +- most common known failure move +- basic phase and failure-move analytics + +## Technology + +- React +- TypeScript +- Vite +- FastAPI +- Pydantic +- JSON persistence +- GitHub Actions +- Docker + +V1 intentionally validated one complete vertical slice before expanding the dataset. + +Released as `v1.0.0`. + +--- + +# V2 — Structured Sekiro Analytics Platform ✅ + +## Core Question + +> **Where am I improving or struggling?** + +V2 evolved the JSON-backed MVP into a structured, relational, multi-boss application. + +Delivered: + +- PostgreSQL persistence +- SQLAlchemy ORM +- Alembic migrations +- relational `Game → Boss → Phase / Move` domain model +- `phase_moves` many-to-many mapping +- 8 major Sekiro bosses +- richer move metadata +- boss-level source provenance +- progression analytics +- all-time vs. recent-attempt comparisons +- attempts until first victory +- Sekiro-level analytics dashboard +- integration tests +- Docker Compose +- four required CI jobs: + - backend + - frontend + - integration + - docker + +V2 intentionally remained locally deployed. + +Released as `v2.0.0`. --- -## V1 — Manual Boss Attempt Analytics ✅ +# V3 — Multi-User Product Foundation ✅ + +## Core Question + +> **Can different players reliably use the application with their own data?** -**Core question:** -**What killed me?** +V3 turns the local single-user analytics application into a multi-user-capable product foundation. -V1 is the MVP. +V3 does **not** attempt to establish complete engine-level boss movesets. -Players manually record the information they can realistically remember after each boss attempt: +The discovery that Wiki movesets are incomplete or inconsistent changes the role of V3 content: -* result: victory or failure -* phase reached -* failure move, `Other`, or `Not Sure` -* optional notes +> V3 boss data is product-level semantic content, not final gameplay ground truth. -The app provides: +--- + +## Milestone 1 — Accounts and Authentication ✅ -* boss selection -* boss moveset reference -* attempt history -* total attempts -* best phase reached -* main bottleneck phase -* most common known failure move -* basic failure analytics +Delivered: -### Technology +- `users` table +- `user_sessions` table +- argon2 password hashing +- register +- login +- logout +- current-user endpoint +- server-side sessions +- httpOnly session cookie +- only a SHA-256 hash of session tokens stored in PostgreSQL +- login and registration UI -* React + TypeScript -* FastAPI + Pydantic -* JSON persistence -* GitHub Actions CI -* Docker +Boss reference data remains publicly browsable. -V1 intentionally focuses on a small dataset and a complete end-to-end workflow rather than broad boss coverage. +User analytics require authentication. --- -## V2 — Structured Sekiro Analytics Platform ✅ +## Milestone 2 — User-Owned Attempts ✅ -**Core question:** -**Where am I improving or struggling?** +Delivered: + +- `attempts.user_id` +- new attempts belong to the logged-in user +- historical ownerless attempts remain preserved +- ownerless attempts are hidden until explicitly claimed +- migration helper for assigning historical attempts + +No request body may choose another user's `user_id`. -V2 expanded the MVP into a more complete Sekiro analytics application. +--- + +## Milestone 3 — Per-User Analytics and Isolation ✅ Delivered: -* persistence migrated from JSON to PostgreSQL -* SQLAlchemy and Alembic for relational persistence and migrations -* 8 major Sekiro bosses with different phase counts and movesets -* richer move metadata (type, description, telegraph, counter, common mistakes) with a source page for each boss -* attempt progression analytics -* all-time vs. last 10 attempts comparisons -* attempts until first victory -* an overall Sekiro-level analytics dashboard with a boss comparison -* database, integration, and Docker checks in CI, all required before merging -* reliable local deployment with Docker Compose +- per-user attempt history +- per-user boss analytics +- per-user progression analytics +- per-user Sekiro dashboard +- API-level isolation tests +- service-level isolation tests +- cross-user analytics isolation + +A user must never read, modify, or influence another user's attempt analytics. + +--- + +## Milestone 4 — Bilingual Product Interface ✅ + +Add English / Chinese language support for the existing product. + +Delivered: + +- language switch in the navigation +- logged-in users store their language preference in their account +- visitors use browser-local preference +- first visit may default from browser language +- interface text available in both languages +- each language displays only its own text +- no mixed English/Chinese pages except where technically unavoidable +- boss names and existing boss reference content support both languages + +Use a lightweight in-house translation dictionary for interface text unless a larger i18n framework becomes justified. + +Boss-content translations must preserve the meaning of the source material. + +Do not describe translated terminology as official unless an authoritative source supports that claim. + +--- + +## Milestone 5 — Existing Content Cleanup ✅ + +Review the boss data already present in the application. + +The purpose is **not** to prove moveset completeness. + +Goals: + +- remove obvious inaccuracies +- correct misleading descriptions +- preserve source provenance +- avoid placeholder moves +- mark or document uncertainty rather than inventing information +- avoid adding large numbers of new Wiki-derived moves merely to appear complete + +If a move cannot be verified confidently: + +```text +do not invent +do not silently guess +do not force completeness +``` + +It may remain explicitly uncertain or be deferred to V4 ground-truth research. + +This milestone also records a per-boss mapping between the product's moves and the Fextralife and Fandom entries (`research/ground_truth/semantic/wiki_mapping/`). It measures how far the two wikis disagree, which is a lower bound on the gap to ground truth, and it becomes the starting point for V4. + +Delivered: obvious errors and misleading descriptions fixed, phase structures corrected (True Corrupted Monk; Genichiro, Way of Tomoe added to the Isshin fight), actions that cannot end an attempt removed from the movesets, move-level sources for moves taken from another page, and the mapping for all 8 bosses. Across 106 product moves, 52 match one-to-one between the two wikis. + +### Important Boundary + +V3 does **not** require: + +- every Sekiro boss +- every boss animation +- every combat action +- engine-level completeness +- Resurrection moveset completeness +- automatic gameplay observation + +Those belong to V4. + +--- + +## Milestone 6 — V3 Hardening and Release ✅ + +Before release: + +- backend tests pass +- frontend unit tests pass +- frontend typecheck passes +- frontend lint passes +- frontend-backend integration tests pass +- Docker Compose build and smoke test pass +- authentication isolation remains covered +- bilingual behavior is tested +- data survives a Docker Compose restart +- documentation reflects the new roadmap + +The application remains reproducibly runnable locally with Docker Compose. + +Release: + +```text +v3.0.0 +``` + +Released as `v3.0.0`. + +--- + +## V3 Non-Goals + +Do not introduce in V3: + +- full engine-derived boss movesets +- gameplay reverse engineering as production architecture +- public hosting +- production cloud infrastructure +- practice recommendation algorithms +- video upload +- computer vision +- automatic move detection +- player-action recognition +- success-rate estimation +- Black Myth: Wukong +- Redis without a demonstrated need +- Kafka or message queues without a demonstrated need +- Kubernetes solely for portfolio complexity +- microservices without a demonstrated need + +--- + +## V3 Completion Target + +V3 is complete when: + +```text +Open Application + ↓ +Choose English / 中文 + ↓ +Register / Log In + ↓ +Browse Existing Boss Reference Data + ↓ +Record Attempts + ↓ +See Only Personal History + ↓ +See Only Personal Analytics + ↓ +Restart Through Docker Compose + ↓ +Data Persists +``` + +V3 proves the application architecture is ready for real user identity and persistent personal data. + +It does not claim the move taxonomy is complete. + +--- + +# V4 — Combat Ground Truth Foundation + +## Core Question + +> **What boss actions actually exist, and what evidence supports each semantic move?** + +V4 establishes the trustworthy combat-data foundation required before video automation. + +This is primarily a: + +```text +reverse engineering ++ +runtime instrumentation ++ +dataset engineering ++ +semantic modeling +``` + +version. + +It is **not primarily a machine-learning version**. + +--- + +# V4 Ground Truth Principle + +No single source is sufficient. + +Ground truth should combine: + +```text +Runtime Truth ++ +Engine/File Truth ++ +Semantic Truth ++ +Gameplay Evidence +``` + +Conceptually: + +```text +Visible Gameplay Action + ↕ +Runtime Behavior / Event + ↕ +Animation / TAE + ↕ +Attack / Bullet / Effect Data + ↕ +Semantic Move +``` + +Wiki and community documentation remain useful for: + +- human-readable names +- descriptions +- terminology +- discovery + +They are not treated as final completeness authorities. + +The project owner makes the final decision on what counts as ground truth. Tooling and assistants gather evidence and propose; they do not mark anything as verified on their own. + +--- + +# V4 Data Layers + +## 1. Ruleset + +Every ground-truth observation belongs to a specific ruleset. + +Examples: + +```text +Sekiro Vanilla +Sekiro Resurrection +``` + +Rulesets must include version metadata where possible. + +Do not mix Vanilla and Resurrection observations without explicitly labeling them. + +--- + +## 2. Character Identity + +Map product bosses to game-engine character identities. + +Conceptually: + +```text +Boss + ↓ +Boss Variant / Ruleset + ↓ +Character ID +``` + +One product boss may involve multiple internal character IDs or encounter variants. + +--- + +## 3. Runtime Observation + +Capture what the game reports while a boss action actually occurs. + +Possible evidence includes: + +- current behavior +- fired behavior events +- runtime state +- runtime animation identifiers +- timestamps +- phase +- gameplay context + +Debug/developer tooling should be preferred where it exposes reliable runtime state. + +--- + +## 4. Engine Action + +Represent low-level game actions separately from semantic moves. + +An engine action may contain: + +- character ID +- animation ID +- runtime event +- TAE metadata +- attack parameter references +- projectile references +- effect references +- ruleset +- phase/context information + +Do not assume: + +```text +1 animation = 1 semantic move +``` + +--- + +## 5. Semantic Move + +The existing product-level `Move` concept should be treated conceptually as a semantic move: + +```text +Floating Passage +Perilous Thrust +Sweep Follow-Up +Lightning Attack +``` + +A semantic move may map to several engine actions. + +An engine action may also be reused in several semantic contexts. + +The likely relationship is therefore: + +```text +SemanticMove + ↕ +many-to-many + ↕ +EngineAction +``` + +Do not force a one-to-one schema before the evidence supports it. + +--- + +## 6. Gameplay Evidence + +Every high-confidence mapping should eventually include observed gameplay examples. + +Example: + +```text +video +timestamp_start +timestamp_end +boss +ruleset +phase +semantic_move +runtime_events +engine_actions +``` + +These clips later become the foundation of the V5 video dataset. + +--- + +# V4 Milestone 1 — Ground Truth Research Workspace + +Create a research area separate from production application data. + +Suggested structure: + +```text +research/ +└── ground_truth/ + ├── README.md + ├── rulesets/ + ├── characters/ + ├── engine/ + ├── semantic/ + ├── observations/ + ├── annotations/ + └── scripts/ +``` + +Do not commit copyrighted raw game assets. + +Do not commit: + +- extracted game archives +- proprietary animation files +- full game binaries +- large captured gameplay videos unless distribution rights are clear + +Commit only: + +- metadata +- mappings +- scripts +- annotations +- hashes +- derived research data that is safe to distribute + +--- + +# V4 Milestone 2 — Genichiro Runtime Ground Truth Pilot -V2 remains focused specifically on **Sekiro**. +Use Genichiro as the first complete vertical slice. -Not included in V2: +Start with Vanilla. -* 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 +Objectives: -### Completion target (met) +- identify relevant character IDs +- enable runtime/debug instrumentation +- record gameplay with debug information visible or logged +- observe representative attacks +- capture runtime event/state/animation identifiers +- align timestamps with visible gameplay actions -A stable multi-boss Sekiro analytics application backed by PostgreSQL, running locally, that can show both boss-specific and game-level player progression. +Initial success does not require every move. + +First prove: + +```text +Visible Genichiro Action + ↓ +Runtime Identifier +``` + +for several representative attacks. + +--- + +# V4 Milestone 3 — Static Engine Mapping + +Starting from known runtime identifiers, trace backward into game/mod data. + +Possible layers: + +```text +Runtime Event + ↓ +HKS / Behavior Logic + ↓ +Animation + ↓ +TAE + ↓ +AtkParam / Bullet / SpEffect +``` + +Use tools only where they provide necessary evidence. + +Avoid full-game extraction when selective inspection is sufficient. + +Prefer: + +```text +runtime-first +↓ +targeted static analysis +``` + +over: + +```text +extract everything +↓ +manually inspect thousands of animations +``` + +--- + +# V4 Milestone 4 — Genichiro Semantic Move Catalog + +Create a reviewed mapping between engine actions and semantic moves. + +Each mapping should retain evidence. + +Example shape: + +```json +{ + "move_slug": "floating-passage", + "ruleset": "vanilla", + "character_ids": ["..."], + "phases": [1, 2], + "runtime_events": ["..."], + "animation_ids": ["..."], + "observed_clip_count": 5, + "tae_verified": true, + "confidence": "high" +} +``` + +Actual field names may change as research reveals the real relationships. + +Do not prematurely migrate this schema into production PostgreSQL. + +--- + +# V4 Milestone 5 — Completeness Validation + +For the supported boss, define three sets: + +```text +E = engine combat candidates +R = runtime-observed actions/events +S = semantic moves +``` + +Every engine combat candidate should eventually be classified as one of: + +```text +mapped_to_semantic_move +non_combat +locomotion +hit_reaction +cinematic_or_scripted +duplicate_or_shared +unused_or_unreachable +unknown +``` + +Every runtime-observed combat action should map to: + +```text +runtime observation +→ engine evidence +→ semantic move +``` + +The objective is not to prove mathematical completeness of every possible game state. + +The objective is to make unexplained combat-relevant actions approach zero for the supported boss. + +--- + +# V4 Milestone 6 — Resurrection Mapping + +After Vanilla Genichiro is understood, analyze the Resurrection variant. + +Treat Resurrection as a separate ruleset. + +Use the mod override files as a differential clue: + +```text +Vanilla + ↓ +Compare + ↓ +Resurrection Override +``` + +Classify moves/actions as: + +```text +unchanged +modified +new_in_resurrection +vanilla_only +shared_semantic_move_with_different_engine_sequence +``` + +Do not mix Resurrection training examples with Vanilla examples without ruleset labels. + +--- + +# V4 Milestone 7 — Product Integration + +Once the ground-truth schema is stable enough: + +- connect verified semantic moves back to the application +- correct inaccurate V3 move records +- add verified phase relationships +- expose provenance where useful +- optionally expose verification status internally +- preserve existing attempt references safely + +Only now should production schema changes for engine-action mappings be considered. + +Potential future concepts include: + +```text +rulesets +boss_variants +engine_actions +move_engine_actions +``` + +Do not introduce them until actual research demonstrates the required cardinality and fields. + +--- + +# V4 Milestone 8 — Expand Boss-by-Boss + +After the Genichiro pipeline is proven, expand systematically. + +Prioritize bosses that exercise different combat structures, for example: + +```text +humanoid sword boss +beast boss +multi-phase boss +ranged/projectile-heavy boss +complex late-game boss +``` + +For each boss repeat: + +```text +runtime discovery +↓ +static engine tracing +↓ +semantic mapping +↓ +controlled gameplay validation +↓ +completeness review +``` + +Do not return to Wiki-only bulk content expansion. + +--- + +# V4 Milestone 9 — Public Deployment + +Public deployment moves here from V3. + +Deployment should happen only after: + +- authentication is stable +- personal data isolation is tested +- bilingual UI works +- the application clearly distinguishes verified vs. uncertain combat content +- at least one complete ground-truth vertical slice is integrated +- database migration strategy is stable +- `attempts.user_id` is made NOT NULL, after confirming no ownerless pre-account attempts remain + +Production concerns include: + +- HTTPS +- production PostgreSQL +- environment secrets +- database backups +- migration execution +- secure cookies +- deployment from `main` +- recovery procedures +- observability appropriate to the actual hosting environment + +Do not introduce infrastructure that is not required by the selected deployment platform. + +--- + +# V4 Release Strategy + +Do **not** block the first V4 release on reverse-engineering every main boss. + +Recommended release model: + +```text +v4.0.0 += +ground-truth pipeline proven ++ +Genichiro complete vertical slice ++ +production integration ++ +public deployment +``` + +Then expand verified coverage incrementally: + +```text +v4.1 +v4.2 +v4.3 +... +``` + +Each release can add additional verified bosses. + +This prevents ground-truth completeness from becoming an unbounded release blocker. + +--- + +# V5 — Automated Gameplay Analysis + +## Core Question + +> **What actually happened during this fight without asking the player to manually record everything?** + +V5 uses the V4 ground-truth catalog to reduce or eliminate manual attempt entry. + +The long-term user experience becomes: + +```text +Upload Gameplay + ↓ +Processing + ↓ +Detected Boss / Phase / Moves + ↓ +Detected Player Responses + ↓ +Fight Analytics + ↓ +Practice Suggestions +``` + +--- + +## V5 Milestone 1 — Video Upload and Storage + +Add: + +- gameplay video upload +- user ownership +- metadata +- processing status +- storage lifecycle + +Video blobs do not need to live inside PostgreSQL. + +PostgreSQL should store metadata and references. + +--- + +## V5 Milestone 2 — Annotation Pipeline + +Before automatic ML detection: + +- build manual annotation tooling +- use V4 ground-truth IDs +- label move start/end timestamps +- label phase +- label ruleset +- preserve uncertain labels + +The annotation tool should be useful even if ML never works perfectly. --- -## V3 — Personalized Practice Coach +## V5 Milestone 3 — Semi-Automatic Move Recognition + +Start with assistance rather than full automation. + +Example: + +```text +System: +"This looks like Floating Passage." + +[Confirm] +[Choose Different Move] +``` -**Core question:** -**What should I practice next?** +Measure: -V3 introduces individual player identity and personalized training. +- annotation speed improvement +- precision +- recall +- confidence calibration -Main goals: +--- -* 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 +## V5 Milestone 4 — Automatic Move Detection -Example output: +Progress from: -```text id="7v1njg" -Recommended Practice +```text +short clip +→ move classification +``` -1. Floating Passage -2. Phase 3 Lightning +to: -Next Goal: -Reach Phase 3 for 3 consecutive attempts +```text +full fight +→ temporal segmentation +→ move classification ``` -V3 turns the application from an analytics tracker into a personalized practice assistant. +Do not require whole-fight understanding as the first ML milestone. --- -## V4 — Gameplay Analysis +## V5 Milestone 5 — Player Response Detection + +Detect or infer responses such as: -**Core question:** -**What actually happened during the fight?** +- deflect +- block +- dodge +- Mikiri Counter +- jump counter +- Lightning Reversal +- hit taken +- death -V4 reduces reliance on user memory by analyzing gameplay itself. +Only calculate metrics supported by observed events. -Main goals: +--- + +## V5 Milestone 6 — True Per-Move Analytics + +Once move occurrences and outcomes are observable: + +```text +Floating Passage + +Occurrences: 12 +Handled Successfully: 8 +Failed / Damaged: 4 +``` + +True rates become valid because the denominator is known. + +This is fundamentally different from V1–V3 failure-count analytics. + +--- -* gameplay video upload -* move occurrence annotation -* player response tracking -* manual or semi-automatic gameplay labeling -* computer vision / ML-based boss move detection -* automatic or semi-automatic success/failure detection -* true per-move performance metrics +## V5 Milestone 7 — Practice Recommendations + +Practice recommendations move here from the old V3 roadmap. + +Recommendations should use actual observed gameplay evidence where possible. Example: -```text id="ngyp00" +```text Floating Passage -Encountered: 10 -Successfully Handled: 7 -Failed: 3 +Observed: 8 times +Cleanly handled: 2 +Damaged or failed: 6 -Success Rate: 70% +Suggested focus: +Practice the final sequence of Floating Passage. ``` -Unlike V1–V3, V4 can calculate true per-move success rates because the system knows both how often a move occurred and how the player responded. +Recommendations must remain explainable. + +Do not introduce LLM-generated or opaque skill scores unless a real product need emerges. --- -## V5 — Multi-Game Boss Analytics Platform +# V6 — Multi-Game Boss Analytics Platform -**Core question:** -**Can the same analysis work across games?** +## Core Question -V5 generalizes the platform beyond Sekiro. +> **Can the same ground-truth and gameplay-analysis architecture work beyond Sekiro?** The first planned additional game is: **Black Myth: Wukong** -Main goals: +Goals: -* generalize the domain model across games -* support multiple games within the same application -* preserve game-specific combat mechanics -* reuse attempt and gameplay analytics across games -* explore cross-game weakness and performance analysis +- generalize game/ruleset abstractions +- preserve game-specific mechanics +- reuse video-analysis infrastructure +- reuse annotation infrastructure +- reuse semantic-move concepts where appropriate +- avoid pretending different combat systems share identical mechanics -Shared structure: +Shared high-level hierarchy may remain: -```text id="iiqf8a" +```text Game └── Boss ├── Phase - └── Move - -Phase ←── many-to-many ──→ Move + └── Semantic Move ``` -Examples of game-specific mechanics: - -### Sekiro +Game-specific concepts remain separate. -* Deflect -* Mikiri Counter -* Jump Counter -* Lightning Reversal -* Posture +### Sekiro Examples -### Black Myth: Wukong +- Deflect +- Mikiri Counter +- Jump Counter +- Lightning Reversal +- Posture -* Dodge -* Stance -* Spell -* Transformation -* Focus +### Black Myth: Wukong Examples -At this stage, the project evolves from a Sekiro-specific application into a generalized gameplay performance analytics platform. +- Dodge +- Stance +- Spell +- Transformation +- Focus --- # Version Summary -```text id="v5nnj2" +```text V1 What killed me? → Manual attempt analytics V2 Where am I improving or struggling? -→ Structured Sekiro analytics +→ Structured relational analytics V3 -What should I practice next? -→ Personalized practice coach +Can different players reliably use it? +→ Multi-user product foundation V4 -What actually happened during the fight? -→ Gameplay / video analysis +What moves actually exist? +→ Combat ground truth + public product V5 +What happened in the fight automatically? +→ Video / CV gameplay analysis + +V6 Can this work across games? → Multi-game analytics platform ``` --- -# Guiding Principle +# Guiding Principles + +## Correctness Before Apparent Completeness + +Do not add uncertain moves merely so the dataset looks complete. + +```text +verified smaller dataset +> +large unreliable dataset +``` + +--- -Do not add technology simply because it is commonly used in production systems. +## Runtime and Engine Evidence Before Wiki Authority + +Wiki data is useful for semantic naming and documentation. + +It is not assumed to be complete ground truth. + +--- + +## Preserve Lightweight Manual Tracking + +Until video automation is reliable, the manual workflow remains: + +```text +Result +Phase Reached +Failure Move / Other / Not Sure +Optional Notes +``` + +Do not turn manual attempt entry into combat telemetry. + +--- -Each major technical addition should support a clear product need: +## Maintain Honest Analytics -```text id="3cxkh4" +Before gameplay observation: + +```text +failure count +``` + +is valid. + +Without move-occurrence counts: + +```text +success rate +``` + +is not valid. + +True success rates begin only when V5 provides denominators. + +--- + +## Add Technology Only for a Product or Research Need + +```text JSON -→ enough for the V1 MVP +→ sufficient for V1 PostgreSQL -→ needed for richer structured persistence in V2 +→ relational persistence in V2 Authentication -→ needed for personalized users in V3 +→ multi-user ownership in V3 + +Runtime / reverse-engineering tooling +→ trustworthy combat ground truth in V4 Public hosting -→ needed once there are multiple users, after V3 adds accounts +→ useful once product and content reliability justify exposure -Video / ML pipeline -→ needed for gameplay observation in V4 +Video / CV +→ reducing manual data-entry friction in V5 Multi-game abstraction -→ needed for Black Myth: Wukong and other games in V5 +→ required only in V6 ``` -Keep each version focused, complete, and independently releasable before moving to the next. +Avoid: + +- unnecessary microservices +- Redis without a concrete need +- Kafka without a concrete need +- Kubernetes solely for complexity +- speculative infrastructure + +--- + +# Final Project Direction + +The long-term product goal is no longer merely: + +> manually record boss attempts and view statistics. + +The long-term goal is: + +> **turn gameplay footage into trustworthy, explainable boss-practice analytics while minimizing manual input from the player.** + +The project should progress toward that goal incrementally, with each version establishing the foundation required by the next. \ No newline at end of file diff --git a/ROADMAP.zh-CN.md b/ROADMAP.zh-CN.md new file mode 100644 index 0000000..71deebd --- /dev/null +++ b/ROADMAP.zh-CN.md @@ -0,0 +1,1186 @@ +# Sekiro Boss Practice Analytics — 路线图 + +[English](ROADMAP.md) | 简体中文 + +本路线图规划了 **Sekiro Boss Practice Analytics** 的演进方向。 + +项目最初只是一个轻量的 Boss 尝试手动记录工具,但在开发过程中暴露出一个重要的限制: + +> Wiki 级别的 Boss 招式资料适合作为方便阅读的产品内容,但不够可靠,无法作为实战分析所需的完整真实数据。 + +有些 Boss 招式缺失,不同社区资料对招式的归类方式不一致,描述也有不准确的地方。由于后续的实战分析功能依赖一套可信的招式分类体系,所以必须先基于游戏引擎和运行时建立真实数据,再做自动化的录像分析。 + +因此,路线图分为以下几层: + +```text +产品基础 + ↓ +战斗真实数据 + ↓ +自动理解实战录像 + ↓ +推广到多款游戏 +``` + +每个版本都应当回答一个清晰的技术和产品问题。 + +--- + +# V1 — 手动记录的 Boss 尝试分析 ✅ + +## 核心问题 + +> **是什么打败了我?** + +V1 建立了最小的完整产品流程。 + +玩家手动记录每次挑战 Boss 后实际能回忆起来的信息: + +- 结果:胜利或失败 +- 到达的阶段 +- 失败招式、`Other`(其他)或 `Not Sure`(不确定) +- 可选备注 + +应用提供: + +- Boss 选择 +- Boss 仪表盘 +- 记录尝试 +- 尝试历史 +- 总尝试次数 +- 最好到达的阶段 +- 是否已击败 +- 主要瓶颈阶段 +- 最常见的已知失败招式 +- 按阶段和按失败招式的基础分析 + +## 技术 + +- React +- TypeScript +- Vite +- FastAPI +- Pydantic +- JSON 持久化 +- GitHub Actions +- Docker + +V1 刻意先验证一条完整的纵向功能链路,再去扩充数据集。 + +已发布为 `v1.0.0`。 + +--- + +# V2 — 结构化的只狼分析平台 ✅ + +## 核心问题 + +> **我在哪里进步、在哪里卡住?** + +V2 把基于 JSON 的 MVP 发展成了一个结构化、关系型、支持多个 Boss 的应用。 + +已交付: + +- PostgreSQL 持久化 +- SQLAlchemy ORM +- Alembic 数据库迁移 +- 关系型的 `Game → Boss → Phase / Move` 领域模型 +- `phase_moves` 多对多映射 +- 8 个主要的只狼 Boss +- 更丰富的招式元数据 +- Boss 级别的数据来源记录 +- 进度分析 +- 全部历史与最近尝试的对比 +- 首次胜利前的尝试次数 +- 只狼总览仪表盘 +- 集成测试 +- Docker Compose +- 4 个必须通过的 CI 任务: + - backend + - frontend + - integration + - docker + +V2 刻意只在本地部署。 + +已发布为 `v2.0.0`。 + +--- + +# V3 — 多用户产品基础 ✅ + +## 核心问题 + +> **不同的玩家能否可靠地使用这个应用,并管理各自的数据?** + +V3 把本地的单用户分析应用,变成一个能够支持多用户的产品基础。 + +V3 **不**尝试建立完整的、引擎级别的 Boss 招式数据。 + +发现 Wiki 招式资料不完整、不一致之后,V3 中 Boss 内容的定位也随之改变: + +> V3 的 Boss 数据是产品层面的语义内容,而不是最终的实战真实数据。 + +--- + +## Milestone 1 — 账号与认证 ✅ + +已交付: + +- `users` 表 +- `user_sessions` 表 +- argon2 密码哈希 +- 注册 +- 登录 +- 登出 +- 当前用户接口 +- 服务器端会话 +- httpOnly 会话 cookie +- PostgreSQL 中只保存会话 token 的 SHA-256 哈希 +- 登录和注册界面 + +Boss 参考数据仍然可以公开浏览。 + +用户分析需要登录。 + +--- + +## Milestone 2 — 攻略记录归属用户 ✅ + +已交付: + +- `attempts.user_id` +- 新的尝试归属于当前登录的用户 +- 历史上没有归属的尝试全部保留 +- 没有归属的尝试在被明确认领之前保持隐藏 +- 用于分配历史尝试的迁移辅助脚本 + +任何请求体都不能指定其他用户的 `user_id`。 + +--- + +## Milestone 3 — 按用户的分析与隔离 ✅ + +已交付: + +- 按用户的尝试历史 +- 按用户的 Boss 分析 +- 按用户的进度分析 +- 按用户的只狼总览仪表盘 +- API 层的隔离测试 +- 服务层的隔离测试 +- 跨用户的分析隔离 + +用户永远不能读取、修改或影响其他用户的尝试分析。 + +--- + +## Milestone 4 — 中英双语产品界面 ✅ + +为现有产品加入英文 / 中文支持。 + +已交付: + +- 导航栏中的语言切换 +- 登录用户的语言偏好保存在账号里 +- 访客使用浏览器本地保存的偏好 +- 首次访问时可以根据浏览器语言选择默认语言 +- 界面文字提供两种语言 +- 每种语言只显示该语言的文字 +- 除非技术上无法避免,否则页面上不混排中英文 +- Boss 名称和现有的 Boss 参考内容支持两种语言 + +界面文字使用自己实现的轻量翻译表,除非将来有理由引入更大的 i18n 框架。 + +Boss 内容的翻译必须保留原始资料的含义。 + +除非有权威来源支持,否则不要把翻译出来的术语说成官方名称。 + +--- + +## Milestone 5 — 清理现有内容 ✅ + +审核应用中已有的 Boss 数据。 + +目的**不是**证明招式数据是完整的。 + +目标: + +- 移除明显的错误 +- 修正有误导性的描述 +- 保留数据来源记录 +- 避免占位用的招式 +- 对不确定的内容进行标注或记录,而不是编造信息 +- 不要为了显得完整而大量新增来自 Wiki 的招式 + +如果一个招式无法被有把握地验证: + +```text +不要编造 +不要悄悄猜测 +不要强求完整 +``` + +它可以保持明确的"不确定"状态,或者留给 V4 的真实数据研究。 + +这个里程碑还会为每个 Boss 记录一份产品招式与 Fextralife、Fandom 条目之间的对照表(`research/ground_truth/semantic/wiki_mapping/`)。它衡量两个 Wiki 之间的分歧有多大,这是和真实数据之间差距的下限;它也是 V4 工作的起点。 + +已交付:修正了明显的错误和有误导性的描述;修正了阶段结构(宫内破戒僧;剑圣一心战加入了巴流 苇名弦一郎阶段);从招式列表中移除了不会结束一次尝试的动作;为取自其他页面的招式记录了招式级来源;完成了全部 8 个 Boss 的对照表。在 106 个产品招式中,两个 Wiki 能一一对应的有 52 个。 + +### 重要边界 + +V3 **不**要求: + +- 所有只狼 Boss +- 所有 Boss 动画 +- 所有战斗动作 +- 引擎级别的完整性 +- Resurrection 模组招式的完整性 +- 自动的实战观测 + +这些都属于 V4。 + +--- + +## Milestone 6 — V3 加固与发布 ✅ + +发布前: + +- 后端测试通过 +- 前端单元测试通过 +- 前端类型检查通过 +- 前端 lint 通过 +- 前后端集成测试通过 +- Docker Compose 构建和冒烟测试通过 +- 认证隔离仍然有测试覆盖 +- 双语行为有测试覆盖 +- 重启 Docker Compose 后数据仍然保留 +- 文档反映新的路线图 + +应用仍然可以通过 Docker Compose 在本地可复现地运行。 + +发布: + +```text +v3.0.0 +``` + +已发布为 `v3.0.0`。 + +--- + +## V3 不做的内容 + +V3 不引入: + +- 完整的、来自引擎的 Boss 招式数据 +- 把实战逆向工程作为产品架构的一部分 +- 公网托管 +- 生产环境的云基础设施 +- 练习建议算法 +- 录像上传 +- 计算机视觉 +- 自动招式识别 +- 玩家动作识别 +- 成功率估算 +- 黑神话:悟空 +- 没有实际需求的 Redis +- 没有实际需求的 Kafka 或消息队列 +- 仅为了作品集显得复杂而引入的 Kubernetes +- 没有实际需求的微服务 + +--- + +## V3 完成目标 + +满足以下流程时,V3 即完成: + +```text +打开应用 + ↓ +选择 English / 中文 + ↓ +注册 / 登录 + ↓ +浏览现有的 Boss 参考数据 + ↓ +记录尝试 + ↓ +只看到自己的历史 + ↓ +只看到自己的分析 + ↓ +通过 Docker Compose 重启 + ↓ +数据仍然保留 +``` + +V3 证明应用架构已经可以支持真实的用户身份和持久保存的个人数据。 + +它并不声称招式分类体系是完整的。 + +--- + +# V4 — 战斗真实数据基础 + +## 核心问题 + +> **Boss 到底有哪些动作,每个语义招式又有什么证据支持?** + +V4 建立可信的战斗数据基础,这是做录像自动化之前的前提。 + +这个版本主要是: + +```text +逆向工程 ++ +运行时监测 ++ +数据集工程 ++ +语义建模 +``` + +它**主要不是一个机器学习版本**。 + +--- + +# V4 真实数据原则 + +没有任何单一来源是足够的。 + +真实数据应当结合: + +```text +运行时事实 ++ +引擎/文件事实 ++ +语义事实 ++ +实战证据 +``` + +在概念上: + +```text +可见的实战动作 + ↕ +运行时行为 / 事件 + ↕ +动画 / TAE + ↕ +攻击 / 子弹 / 特效数据 + ↕ +语义招式 +``` + +Wiki 和社区资料仍然适用于: + +- 方便阅读的名称 +- 描述 +- 术语 +- 发现新招式 + +但它们不被当作完整性的最终依据。 + +哪些内容最终算作真实数据,由项目负责人决定。工具和 AI 助手只负责收集证据、提出建议,不会自行把任何内容标记为已验证。 + +--- + +# V4 数据层 + +## 1. 规则集(Ruleset) + +每条真实数据观测都属于一个特定的规则集。 + +例如: + +```text +只狼原版 +只狼 Resurrection 模组 +``` + +规则集应尽可能包含版本元数据。 + +不要在没有明确标注的情况下混用原版和 Resurrection 的观测数据。 + +--- + +## 2. 角色身份 + +把产品中的 Boss 映射到游戏引擎中的角色身份。 + +在概念上: + +```text +Boss + ↓ +Boss 变体 / 规则集 + ↓ +角色 ID +``` + +一个产品中的 Boss 可能对应多个内部角色 ID 或多种遭遇变体。 + +--- + +## 3. 运行时观测 + +在 Boss 动作实际发生时,记录游戏报告的信息。 + +可能的证据包括: + +- 当前行为 +- 触发的行为事件 +- 运行时状态 +- 运行时动画标识 +- 时间戳 +- 阶段 +- 实战上下文 + +在能够提供可靠运行时状态的情况下,应优先使用调试/开发者工具。 + +--- + +## 4. 引擎动作 + +把底层的游戏动作和语义招式分开表示。 + +一个引擎动作可能包含: + +- 角色 ID +- 动画 ID +- 运行时事件 +- TAE 元数据 +- 攻击参数引用 +- 投射物引用 +- 特效引用 +- 规则集 +- 阶段/上下文信息 + +不要假设: + +```text +1 个动画 = 1 个语义招式 +``` + +--- + +## 5. 语义招式 + +现有产品中的 `Move` 概念,在概念上应当被视为语义招式: + +```text +Floating Passage +Perilous Thrust +Sweep Follow-Up +Lightning Attack +``` + +一个语义招式可能对应多个引擎动作。 + +一个引擎动作也可能在多个语义场景中被复用。 + +因此它们之间很可能是: + +```text +SemanticMove + ↕ +多对多 + ↕ +EngineAction +``` + +在证据支持之前,不要强行采用一对一的结构。 + +--- + +## 6. 实战证据 + +每一个高置信度的映射,最终都应当包含观测到的实战样例。 + +例如: + +```text +video +timestamp_start +timestamp_end +boss +ruleset +phase +semantic_move +runtime_events +engine_actions +``` + +这些片段之后会成为 V5 录像数据集的基础。 + +--- + +# V4 Milestone 1 — 真实数据研究工作区 + +建立一个和产品数据分开的研究区域。 + +建议的结构: + +```text +research/ +└── ground_truth/ + ├── README.md + ├── rulesets/ + ├── characters/ + ├── engine/ + ├── semantic/ + ├── observations/ + ├── annotations/ + └── scripts/ +``` + +不要提交受版权保护的游戏原始素材。 + +不要提交: + +- 解包出来的游戏归档 +- 专有的动画文件 +- 完整的游戏程序文件 +- 分发权不明确的大型实战录像 + +只提交: + +- 元数据 +- 映射 +- 脚本 +- 标注 +- 哈希 +- 可以安全分发的派生研究数据 + +--- + +# V4 Milestone 2 — 苇名弦一郎运行时真实数据试点 + +以苇名弦一郎作为第一个完整的纵向样例。 + +从原版开始。 + +目标: + +- 找出相关的角色 ID +- 启用运行时/调试监测 +- 在显示或记录调试信息的情况下录制实战 +- 观察有代表性的攻击 +- 记录运行时事件/状态/动画标识 +- 把时间戳和可见的实战动作对齐 + +初步成功不要求覆盖所有招式。 + +先针对几个有代表性的攻击证明: + +```text +可见的弦一郎动作 + ↓ +运行时标识 +``` + +--- + +# V4 Milestone 3 — 静态引擎映射 + +从已知的运行时标识出发,反向追溯到游戏/模组数据中。 + +可能涉及的层次: + +```text +运行时事件 + ↓ +HKS / 行为逻辑 + ↓ +动画 + ↓ +TAE + ↓ +AtkParam / Bullet / SpEffect +``` + +只在能提供必要证据时才使用工具。 + +能有针对性地查看时,就不要解包整个游戏。 + +优先: + +```text +运行时优先 +↓ +有针对性的静态分析 +``` + +而不是: + +```text +全部解包 +↓ +手动查看成千上万个动画 +``` + +--- + +# V4 Milestone 4 — 苇名弦一郎语义招式目录 + +建立一份经过审核的引擎动作与语义招式之间的映射。 + +每个映射都应保留证据。 + +示例结构: + +```json +{ + "move_slug": "floating-passage", + "ruleset": "vanilla", + "character_ids": ["..."], + "phases": [1, 2], + "runtime_events": ["..."], + "animation_ids": ["..."], + "observed_clip_count": 5, + "tae_verified": true, + "confidence": "high" +} +``` + +实际的字段名可能会随着研究揭示的真实关系而变化。 + +不要过早把这个结构迁移到生产环境的 PostgreSQL 中。 + +--- + +# V4 Milestone 5 — 完整性验证 + +针对所支持的 Boss,定义三个集合: + +```text +E = 引擎中的战斗候选动作 +R = 运行时观测到的动作/事件 +S = 语义招式 +``` + +每个引擎战斗候选动作最终都应被归入以下类别之一: + +```text +mapped_to_semantic_move(已映射到语义招式) +non_combat(非战斗) +locomotion(移动) +hit_reaction(受击反应) +cinematic_or_scripted(过场或脚本) +duplicate_or_shared(重复或共用) +unused_or_unreachable(未使用或无法触发) +unknown(未知) +``` + +每个运行时观测到的战斗动作都应能对应到: + +```text +运行时观测 +→ 引擎证据 +→ 语义招式 +``` + +目标不是从数学上证明所有可能的游戏状态都已覆盖。 + +目标是让所支持的 Boss 中无法解释的战斗相关动作趋近于零。 + +--- + +# V4 Milestone 6 — Resurrection 映射 + +在弄清原版苇名弦一郎之后,再分析 Resurrection 版本。 + +把 Resurrection 作为一个独立的规则集。 + +利用模组的覆盖文件作为差异线索: + +```text +原版 + ↓ +对比 + ↓ +Resurrection 覆盖文件 +``` + +把招式/动作归类为: + +```text +unchanged(未改变) +modified(有修改) +new_in_resurrection(Resurrection 新增) +vanilla_only(仅原版) +shared_semantic_move_with_different_engine_sequence(语义招式相同,但引擎动作序列不同) +``` + +不要在没有规则集标签的情况下,把 Resurrection 的训练样例和原版样例混在一起。 + +--- + +# V4 Milestone 7 — 产品集成 + +当真实数据的结构足够稳定后: + +- 把经过验证的语义招式接回应用 +- 修正 V3 中不准确的招式记录 +- 加入经过验证的阶段关系 +- 在有用的地方展示数据来源 +- 视情况在内部展示验证状态 +- 安全地保留现有尝试的引用 + +只有到这时,才应考虑为引擎动作映射修改生产环境的数据库结构。 + +将来可能出现的概念包括: + +```text +rulesets +boss_variants +engine_actions +move_engine_actions +``` + +在实际研究证明需要什么样的对应关系和字段之前,不要引入这些概念。 + +--- + +# V4 Milestone 8 — 逐个扩展 Boss + +在苇名弦一郎的流程被验证可行之后,再系统地扩展。 + +优先选择战斗结构各不相同的 Boss,例如: + +```text +人形剑士 Boss +野兽型 Boss +多阶段 Boss +远程/投射物较多的 Boss +复杂的后期 Boss +``` + +对每个 Boss 重复: + +```text +运行时发现 +↓ +静态引擎追溯 +↓ +语义映射 +↓ +受控的实战验证 +↓ +完整性审核 +``` + +不要回到只依赖 Wiki 的批量内容扩充。 + +--- + +# V4 Milestone 9 — 公网部署 + +公网部署从 V3 移到了这里。 + +只有在以下条件满足后才进行部署: + +- 认证稳定 +- 个人数据隔离经过测试 +- 双语界面可用 +- 应用能清楚区分经过验证和不确定的战斗内容 +- 至少集成了一个完整的真实数据纵向样例 +- 数据库迁移策略稳定 +- 确认已经没有未归属的旧尝试后,把 `attempts.user_id` 改为 NOT NULL + +生产环境需要考虑: + +- HTTPS +- 生产环境的 PostgreSQL +- 环境变量中的密钥 +- 数据库备份 +- 迁移的执行 +- 安全的 cookie +- 从 `main` 部署 +- 恢复流程 +- 与实际托管环境相适应的可观测性 + +不要引入所选部署平台并不需要的基础设施。 + +--- + +# V4 发布策略 + +**不要**让第一个 V4 版本等到所有主要 Boss 都完成逆向工程才发布。 + +推荐的发布方式: + +```text +v4.0.0 += +真实数据流程验证可行 ++ +苇名弦一郎的完整纵向样例 ++ +产品集成 ++ +公网部署 +``` + +之后逐步扩大经过验证的覆盖范围: + +```text +v4.1 +v4.2 +v4.3 +... +``` + +每个版本可以加入更多经过验证的 Boss。 + +这样可以避免"真实数据的完整性"变成一个没有边界的发布障碍。 + +--- + +# V5 — 自动实战分析 + +## 核心问题 + +> **不需要玩家手动记录一切,这场战斗里实际发生了什么?** + +V5 利用 V4 的真实数据目录,减少甚至取消手动记录尝试。 + +长期的用户体验将变成: + +```text +上传实战录像 + ↓ +处理中 + ↓ +识别出的 Boss / 阶段 / 招式 + ↓ +识别出的玩家应对 + ↓ +战斗分析 + ↓ +练习建议 +``` + +--- + +## V5 Milestone 1 — 录像上传与存储 + +加入: + +- 实战录像上传 +- 用户归属 +- 元数据 +- 处理状态 +- 存储的生命周期管理 + +录像文件本身不需要放进 PostgreSQL。 + +PostgreSQL 应当保存元数据和引用。 + +--- + +## V5 Milestone 2 — 标注流程 + +在做自动的机器学习识别之前: + +- 构建手动标注工具 +- 使用 V4 的真实数据 ID +- 标注招式的开始/结束时间戳 +- 标注阶段 +- 标注规则集 +- 保留不确定的标注 + +即使机器学习永远做不到完美,这个标注工具本身也应当有用。 + +--- + +## V5 Milestone 3 — 半自动招式识别 + +先从辅助开始,而不是直接完全自动化。 + +例如: + +```text +系统: +"这看起来像是飞渡浮舟。" + +[确认] +[选择其他招式] +``` + +衡量: + +- 标注速度的提升 +- 精确率 +- 召回率 +- 置信度校准 + +--- + +## V5 Milestone 4 — 自动招式识别 + +从: + +```text +短片段 +→ 招式分类 +``` + +逐步发展到: + +```text +整场战斗 +→ 时间切分 +→ 招式分类 +``` + +不要把理解整场战斗作为第一个机器学习里程碑。 + +--- + +## V5 Milestone 5 — 玩家应对识别 + +识别或推断以下应对: + +- 弹刀 +- 防御 +- 闪避 +- 识破 +- 跳跃反击 +- 雷反 +- 受到伤害 +- 死亡 + +只计算有观测事件支持的指标。 + +--- + +## V5 Milestone 6 — 真正的按招式分析 + +当招式的出现次数和结果都可以被观测到时: + +```text +飞渡浮舟 + +出现次数:12 +成功应对:8 +失败 / 受到伤害:4 +``` + +因为分母是已知的,真正的比率才变得有效。 + +这和 V1–V3 基于失败次数的分析有本质区别。 + +--- + +## V5 Milestone 7 — 练习建议 + +练习建议从旧路线图的 V3 移到了这里。 + +练习建议应尽可能基于实际观测到的实战证据。 + +例如: + +```text +飞渡浮舟 + +观测到:8 次 +干净应对:2 次 +受到伤害或失败:6 次 + +建议重点: +练习飞渡浮舟的最后一段。 +``` + +练习建议必须保持可解释。 + +除非出现真实的产品需求,否则不要引入 LLM 生成的建议或不透明的技能评分。 + +--- + +# V6 — 多游戏 Boss 分析平台 + +## 核心问题 + +> **同一套真实数据和实战分析架构,能否用于只狼以外的游戏?** + +计划加入的第一款游戏是: + +**黑神话:悟空** + +目标: + +- 泛化游戏/规则集的抽象 +- 保留各游戏特有的机制 +- 复用录像分析的基础设施 +- 复用标注的基础设施 +- 在合适的地方复用语义招式的概念 +- 不假装不同的战斗系统拥有相同的机制 + +共用的高层结构可以保持为: + +```text +Game + └── Boss + ├── Phase + └── Semantic Move +``` + +各游戏特有的概念保持独立。 + +### 只狼的例子 + +- 弹刀 +- 识破 +- 跳跃反击 +- 雷反 +- 躯干 + +### 黑神话:悟空的例子 + +- 闪避 +- 棍法 +- 法术 +- 变身 +- 棍势 + +--- + +# 版本总结 + +```text +V1 +是什么打败了我? +→ 手动记录的尝试分析 + +V2 +我在哪里进步、在哪里卡住? +→ 结构化的关系型分析 + +V3 +不同的玩家都能可靠地使用吗? +→ 多用户产品基础 + +V4 +到底有哪些招式? +→ 战斗真实数据 + 公开的产品 + +V5 +能否自动知道战斗中发生了什么? +→ 录像 / 计算机视觉实战分析 + +V6 +能用于其他游戏吗? +→ 多游戏分析平台 +``` + +--- + +# 指导原则 + +## 先保证正确,再追求看起来完整 + +不要为了让数据集看起来完整而加入不确定的招式。 + +```text +经过验证的小数据集 +> +不可靠的大数据集 +``` + +--- + +## 运行时和引擎证据优先于 Wiki 权威 + +Wiki 数据适用于语义命名和文档。 + +它不被假定为完整的真实数据。 + +--- + +## 保持轻量的手动记录 + +在录像自动化可靠之前,手动记录流程保持为: + +```text +结果 +到达阶段 +失败招式 / 其他 / 不确定 +可选备注 +``` + +不要把手动记录尝试变成战斗遥测数据录入。 + +--- + +## 保持诚实的分析 + +在有实战观测之前: + +```text +失败次数 +``` + +是有效的。 + +没有招式出现次数时: + +```text +成功率 +``` + +是无效的。 + +真正的成功率要等到 V5 提供分母之后才开始有效。 + +--- + +## 只为产品或研究需求引入技术 + +```text +JSON +→ 对 V1 来说已经足够 + +PostgreSQL +→ V2 的关系型持久化 + +认证 +→ V3 的多用户归属 + +运行时 / 逆向工程工具 +→ V4 的可信战斗真实数据 + +公网托管 +→ 当产品和内容的可靠性足以支撑对外开放时才有意义 + +录像 / 计算机视觉 +→ V5 减少手动录入的麻烦 + +多游戏抽象 +→ 只在 V6 才需要 +``` + +避免: + +- 不必要的微服务 +- 没有具体需求的 Redis +- 没有具体需求的 Kafka +- 仅为了显得复杂而引入的 Kubernetes +- 投机性的基础设施 + +--- + +# 最终项目方向 + +长期的产品目标不再只是: + +> 手动记录 Boss 尝试并查看统计数据。 + +长期目标是: + +> **把实战录像变成可信、可解释的 Boss 练习分析,同时尽量减少玩家的手动输入。** + +项目应当朝着这个目标逐步推进,每个版本都为下一个版本打下所需的基础。 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/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/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/alembic/versions/6849ddf003e3_add_move_source.py b/backend/alembic/versions/6849ddf003e3_add_move_source.py new file mode 100644 index 0000000..793d144 --- /dev/null +++ b/backend/alembic/versions/6849ddf003e3_add_move_source.py @@ -0,0 +1,35 @@ +"""add move source + +Revision ID: 6849ddf003e3 +Revises: 4b0d6abd4d80 +Create Date: 2026-10-03 02:32:35.605013 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6849ddf003e3' +down_revision: Union[str, Sequence[str], None] = '4b0d6abd4d80' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema: an optional per-move source, for a move whose data comes from a + different page than its boss's. Null means the move uses the boss's source. + """ + op.add_column('moves', sa.Column('source_name', sa.String(length=200), nullable=True)) + op.add_column('moves', sa.Column('source_url', sa.Text(), nullable=True)) + # Autogenerate does not detect check constraints; this mirrors models.Move. + op.create_check_constraint('ck_moves_source_complete', 'moves', '(source_name IS NULL) = (source_url IS NULL)') + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('ck_moves_source_complete', 'moves', type_='check') + op.drop_column('moves', 'source_url') + op.drop_column('moves', 'source_name') diff --git a/backend/alembic/versions/76532dd774e5_shift_isshin_attempts_for_way_of_tomoe_.py b/backend/alembic/versions/76532dd774e5_shift_isshin_attempts_for_way_of_tomoe_.py new file mode 100644 index 0000000..cf189ae --- /dev/null +++ b/backend/alembic/versions/76532dd774e5_shift_isshin_attempts_for_way_of_tomoe_.py @@ -0,0 +1,35 @@ +"""shift isshin attempts for way of tomoe phase + +The Isshin, the Sword Saint fight starts with Genichiro, Way of Tomoe. The seed data +now records that as phase 1, so Isshin's own phases move from 1-3 to 2-4. Existing +attempts keep pointing at the same part of the fight by moving up one phase. + +Revision ID: 76532dd774e5 +Revises: 6849ddf003e3 +Create Date: 2026-10-03 02:32:51.211107 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '76532dd774e5' +down_revision: Union[str, Sequence[str], None] = '6849ddf003e3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +ISSHIN = "(SELECT id FROM bosses WHERE slug = 'isshin-sword-saint')" + + +def upgrade() -> None: + """Upgrade data: Isshin attempts move up one phase.""" + op.execute(f"UPDATE attempts SET phase_reached = phase_reached + 1 WHERE boss_id = {ISSHIN}") + + +def downgrade() -> None: + """Downgrade data: attempts in the Way of Tomoe phase have no older equivalent; keep them at phase 1.""" + op.execute(f"UPDATE attempts SET phase_reached = GREATEST(phase_reached - 1, 1) WHERE boss_id = {ISSHIN}") 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..cf2ff96 --- /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 (V4 public deployment) 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..6774154 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -4,6 +4,7 @@ CheckConstraint, DateTime, ForeignKey, + Index, Integer, String, Text, @@ -34,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) @@ -53,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( @@ -62,7 +65,19 @@ 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" + ), + CheckConstraint("(source_name IS NULL) = (source_url IS NULL)", name="ck_moves_source_complete"), + ) id: Mapped[int] = mapped_column(primary_key=True) boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE")) @@ -73,6 +88,20 @@ 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) + # Only for a move whose data comes from a different page than its boss's; + # null means the move uses the boss's source_name / source_url. + source_name: Mapped[str | None] = mapped_column(String(200)) + source_url: 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") @@ -107,10 +136,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,4 +153,39 @@ 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() + + +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") + + +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/analytics.py b/backend/app/models/analytics.py index eecafbb..445ce3e 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -53,6 +53,8 @@ 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 + 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 new file mode 100644 index 0000000..c56de41 --- /dev/null +++ b/backend/app/models/auth.py @@ -0,0 +1,42 @@ +import re +from typing import Literal + +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) + + +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 617de2d..a9d36ca 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,11 +11,32 @@ class BossMove(BaseModel): telegraph: str | None = None 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 + # Set only when this move's data comes from a different page than the boss's source. + source_name: str | None = None + source_url: 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): phase_number: int name: str + name_zh: str | None = None moves: list[BossMove] = [] @@ -23,6 +46,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 @@ -33,3 +57,4 @@ class BossSummary(BaseModel): name: str name_zh: str | None = None location: str + location_zh: str | None = None diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 3e1a7ea..2da518e 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -1,11 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status 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 +# Every endpoint requires login and only ever covers the current user's attempts. router = APIRouter(prefix="/api/bosses/{boss_id}", tags=["attempts"]) @@ -15,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) @@ -38,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/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..b536382 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,57 @@ +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, 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, preferred_language=user.preferred_language) + + +@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) + + +@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/routers/sekiro.py b/backend/app/routers/sekiro.py index 3cc91e0..1123149 100644 --- a/backend/app/routers/sekiro.py +++ b/backend/app/routers/sekiro.py @@ -1,6 +1,8 @@ from fastapi import APIRouter, Depends 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 @@ -9,5 +11,5 @@ @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/seed.py b/backend/app/seed.py index 3c4165b..b9fca4d 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -16,7 +16,26 @@ 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", + "description_zh", + "telegraph_zh", + "counter_zh", + "common_mistakes_zh", + "source_name", + "source_url", +) + +# Every English text field that has a value needs its Chinese version. +TRANSLATED_MOVE_FIELDS = ("description", "telegraph", "counter", "common_mistakes") class SeedError(ValueError): @@ -47,6 +66,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") @@ -57,6 +82,15 @@ 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)") + 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) @@ -106,6 +140,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 @@ -135,6 +170,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/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/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..b2bfd6b --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,96 @@ +"""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 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())) + 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/app/services/boss_service.py b/backend/app/services/boss_service.py index 7d5fc0d..38e13bf 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, @@ -46,6 +51,15 @@ 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, + 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, + source_name=link.move.source_name, + source_url=link.move.source_url, + 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 b5e0c64..c9aaae9 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) + 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) .order_by(db.Attempt.created_at.desc(), db.Attempt.id.desc()) .limit(RECENT_ATTEMPTS_LIMIT) ) @@ -94,14 +100,16 @@ def _recent_attempts(session: Session) -> 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, 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, boss_name_zh, move_slug, move_name, move_name_zh in rows ] @@ -113,15 +121,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 +142,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/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/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/seed/bosses.json b/backend/seed/bosses.json index 064fbc0..abe4a97 100644 --- a/backend/seed/bosses.json +++ b/backend/seed/bosses.json @@ -5,339 +5,658 @@ "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.", - "common_mistakes": null + "counter_zh": "这个阶段他一定会接危·突刺,正好可以轻松识破。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "在这招期间攻击他能打乱他的平衡,即使招式结尾他明显在防御也一样。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot." + "counter_zh": "如果你的躯干快满了,先弹开前两击,然后闪避拉开距离,等他打完再反击。", + "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot.", + "common_mistakes_zh": "连击过程中离他太远——他会用破防射击打断连击。", + "source_name": null, + "source_url": null }, { "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, - "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot." + "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.", + "common_mistakes_zh": "在他面前回复——你每次使用伤药葫芦或其他回复道具,他都会用这一箭回应。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "在起手时打断这招,或者朝他冲刺两次躲开所有箭矢。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "在这招期间攻击他能打乱他的平衡,即使招式结尾他明显在防御也一样。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot." + "counter_zh": "如果你的躯干快满了,先弹开前两击,然后闪避拉开距离,等他打完再反击。", + "common_mistakes": "Getting too far away during the combo - he interrupts it with a Guard Breaking Shot.", + "common_mistakes_zh": "连击过程中离他太远——他会用破防射击打断连击。", + "source_name": null, + "source_url": null }, { "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, - "common_mistakes": "Healing in front of him - he answers every use of the Healing Gourd or another healing item with this shot." + "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.", + "common_mistakes_zh": "在他面前回复——你每次使用伤药葫芦或其他回复道具,他都会用这一箭回应。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "在起手时打断这招,或者朝他冲刺两次躲开所有箭矢。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": "Using an item - he answers it with this thrust." + "counter_zh": null, + "common_mistakes": "Using an item - he answers it with this thrust.", + "common_mistakes_zh": "使用道具——他会用这记突刺回应。", + "source_name": null, + "source_url": null }, { "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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "用雷反把雷电返还给他,能造成大量躯干和体力伤害,并让他短暂麻痹。他的雷电攻击也可以通过攻击他来打断,或者冲刺到他身下躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "用雷反把雷电返还给他,能造成大量躯干和体力伤害,并让他短暂麻痹。他的雷电攻击也可以通过攻击他来打断,或者冲刺到他身下躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "用雷反把雷电返还给他,能造成大量躯干和体力伤害,并让他短暂麻痹。他的雷电攻击也可以通过攻击他来打断,或者冲刺到他身下躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] } @@ -349,279 +668,543 @@ "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", - "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.", - "telegraph": null, - "counter": "Stay ready to react to whichever attack follows.", - "common_mistakes": 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.", - "common_mistakes": null + "counter_zh": "防御、弹刀,或者向侧面闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开或防御手里剑,再向侧面闪避躲开空翻斩,然后反击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破这记突刺。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "两下都防御或弹开。时机准确的话,也可以向右闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跳过去并留在他身边,引诱他出下劈;或者向后跳,踩头反击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack." + "counter_zh": "在最后一刻闪避再反击,或者后退到范围外。他蓄力期间可以打中他。", + "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack.", + "common_mistakes_zh": "闪避太早会让他改成一记快速横斩。", + "source_name": null, + "source_url": null }, { "id": "backflip", "name": "Backflip", - "move_type": "movement", + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "combo", "description": "Leaps away; deals no vitality damage but significant posture damage.", + "description_zh": "向后跃开;不造成体力伤害,但会造成大量躯干伤害。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect to minimize posture damage.", - "common_mistakes": null + "counter_zh": "弹刀以尽量减少躯干伤害。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you." + "counter_zh": "向后闪避躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "防御或弹刀。也可以向左闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival." + "counter_zh": "弹开或防御前几刀,在他扔出爆竹时向右闪避,寻找反击机会。", + "common_mistakes": "Dodging too quickly after the firecrackers are thrown - the follow-up slash will hit you. The combo can also easily push you back into a corner, where there is little chance of survival.", + "common_mistakes_zh": "爆竹扔出后闪避得太快——会被接下来的斩击命中。这套连击也很容易把你逼进角落,那里几乎没有活路。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "跳过去并留在他身边,引诱他出下劈。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向后闪避或受身翻滚躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "id": "mikiri-counter", + "name": "Mikiri Counter", + "name_zh": "识破反击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "counter", + "description": "When attacked with a thrust - including Shadowrush, Shadowfall or the Loaded Spear - he steps on the weapon, leaps behind Wolf and cuts his back, then plunges his sword into Wolf's chest.", + "description_zh": "玩家使用突刺类攻击时(包括绝技·巨型忍者突刺、秘传·巨型忍者落杀和机关长枪),他会踩住武器,跃到狼的身后斩击背部,再把刀刺进狼的胸口。", + "telegraph": null, + "telegraph_zh": null, + "counter": null, + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Owl_(Father)" } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ - { - "id": "zig-zag", - "name": "Zig-Zag", - "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.", - "telegraph": null, - "counter": "Stay ready to react to whichever attack follows.", - "common_mistakes": 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.", - "common_mistakes": null + "counter_zh": "防御、弹刀,或者向侧面闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开或防御手里剑,再向侧面闪避躲开空翻斩,然后反击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破这记突刺。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "两下都防御或弹开。时机准确的话,也可以向右闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跳过去并留在他身边,引诱他出下劈;或者向后跳,踩头反击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack." + "counter_zh": "在最后一刻闪避再反击,或者后退到范围外。他蓄力期间可以打中他。", + "common_mistakes": "Dodging too early makes him switch to a quick horizontal attack.", + "common_mistakes_zh": "闪避太早会让他改成一记快速横斩。", + "source_name": null, + "source_url": null }, { "id": "backflip", "name": "Backflip", - "move_type": "movement", + "name_zh": "后空翻", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "combo", "description": "Leaps away; deals no vitality damage but significant posture damage.", + "description_zh": "向后跃开;不造成体力伤害,但会造成大量躯干伤害。", "telegraph": null, + "telegraph_zh": null, "counter": "Deflect to minimize posture damage.", - "common_mistakes": null + "counter_zh": "弹刀以尽量减少躯干伤害。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Dodging too quickly after the throw lets his follow-up slash hit you." + "counter_zh": "向后闪避躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "防御或弹刀。也可以向左闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Getting pushed back into a corner by the combo, where there is no chance of survival." + "counter_zh": "弹开或防御前几刀,在他扔出爆竹时向右闪避,寻找反击机会。", + "common_mistakes": "Dodging too quickly after the firecrackers are thrown - the follow-up slash will hit you. The combo can also easily push you back into a corner, where there is little chance of survival.", + "common_mistakes_zh": "爆竹扔出后闪避得太快——会被接下来的斩击命中。这套连击也很容易把你逼进角落,那里几乎没有活路。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "跳过去并留在他身边,引诱他出下劈。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向后闪避或受身翻滚躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "两下都防御或弹开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开这一斩,或者跑开、远离那只枭来躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跳过火枭,然后识破那记突刺。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "id": "mikiri-counter", + "name": "Mikiri Counter", + "name_zh": "识破反击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "counter", + "description": "When attacked with a thrust - including Shadowrush, Shadowfall or the Loaded Spear - he steps on the weapon, leaps behind Wolf and cuts his back, then plunges his sword into Wolf's chest.", + "description_zh": "玩家使用突刺类攻击时(包括绝技·巨型忍者突刺、秘传·巨型忍者落杀和机关长枪),他会踩住武器,跃到狼的身后斩击背部,再把刀刺进狼的胸口。", + "telegraph": null, + "telegraph_zh": null, + "counter": null, + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Owl_(Father)" } ] } @@ -633,144 +1216,237 @@ "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.", - "common_mistakes": null - }, - { - "id": "jump-into-rafters", - "name": "Jump into the Rafters", - "move_type": "movement", - "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." + "counter_zh": "可以防御或弹刀。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "闪避或移动躲开。也可以用手里剑把她打下来,获得反击时机。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跳起踩她,能造成可观的躯干伤害。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks." + "counter_zh": "贴近她,弹开攻击,然后在她的收招空档反击。", + "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks.", + "common_mistakes_zh": "攻击太多。弹刀要多于攻击,只在出现破绽后出手,比如她的危攻击之后。", + "source_name": null, + "source_url": null } ] }, { "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.", - "common_mistakes": null - }, - { - "id": "jump-into-rafters", - "name": "Jump into the Rafters", - "move_type": "movement", - "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." + "counter_zh": "可以防御或弹刀。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "闪避或移动躲开。也可以用手里剑把她打下来,获得反击时机。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跳起踩她,能造成可观的躯干伤害。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks." + "counter_zh": "贴近她,弹开攻击,然后在她的收招空档反击。", + "common_mistakes": "Attacking too much. Deflect more than you attack, and only strike after openings such as her perilous attacks.", + "common_mistakes_zh": "攻击太多。弹刀要多于攻击,只在出现破绽后出手,比如她的危攻击之后。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "使用拍手种子驱散幻影,或者逐个击杀它们来获得纸人。也可以沿着场地边缘跑动来躲开它们的攻击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹刀或防御;向侧面闪避最稳妥。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Turning corners while the butterflies are tracking you can make them hit you." + "counter_zh": "躲到柱子后面,或者沿着场地边缘持续奔跑。", + "common_mistakes": "Turning corners while the butterflies are tracking you can make them hit you.", + "common_mistakes_zh": "在幻蝶追踪你时转弯,可能会被它们打中。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "继续用手里剑打断;保持压制,不给她跃到空中的机会。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] } @@ -782,117 +1458,219 @@ "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.", - "common_mistakes": null + "counter_zh": "所有拍地攻击都可以防御;尽量弹刀。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "看准时机跳起躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "看准时机闪避;连续闪避两次(快速按两下)更安全。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "迅速闪避离开毒雾,避免中毒。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null - }, - { - "id": "roar-jump-back", - "name": "Roar / Jump Back", - "move_type": "movement", - "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 + "counter_zh": "在它跃起时跑到它身下再反击;这招的收招硬直很长。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "rolling-body-slam", "name": "Body Slam (Rolling)", - "move_type": "unblockable", + "name_zh": "翻滚压击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "combo", "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.", - "common_mistakes": null + "counter_zh": "远离它的手臂,攻击它的头部。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Staying on top of it and attacking for too long." + "counter_zh": "在它的连击后攻击几下,然后退开。", + "common_mistakes": "Staying on top of it and attacking for too long.", + "common_mistakes_zh": "一直贴着它攻击太久。", + "source_name": null, + "source_url": null + }, + { + "id": "jumping-sweep", + "name": "Jumping Sweep", + "name_zh": "跳跃横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "sweep", + "description": "Jumps high into the air and does a sweep attack upon landing.", + "description_zh": "高高跃起,落地时发动一记横扫。", + "telegraph": null, + "telegraph_zh": null, + "counter": "Jump into it as it lands and kick it, dealing posture damage and forcing it into an overhead slam.", + "counter_zh": "在它落地时跳起踩它,能造成躯干伤害,并迫使它接一记高举砸地。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "diving-lunge", "name": "Diving Lunge", - "move_type": "thrust", + "name_zh": "飞扑", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "combo", "description": "Lunges forward with a swipe while falling to the ground.", - "telegraph": "Clear wind-up before it dives.", + "description_zh": "向前扑出并挥爪,同时扑倒在地。", + "telegraph": null, + "telegraph_zh": null, "counter": "Jump over it as it dives and strike it in the air, following with a full combo.", - "common_mistakes": null + "counter_zh": "在它扑出时跳过去并在空中攻击,然后接一整套连击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开这招会让它硬直并露出脖子。使用机关长枪(发动两次)可以短暂拔出蜈蚣,造成大量躯干和体力伤害。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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+.", - "common_mistakes": null + "counter_zh": "任何机关伞类义手忍具都能完全抵挡这声咆哮;二周目以后推荐使用凤凰紫伞。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] } @@ -901,78 +1679,143 @@ { "id": "corrupted-monk", "name": "Corrupted Monk", - "name_zh": "破戒僧(幻影)", + "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.", - "common_mistakes": null + "counter_zh": "跳过去。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面闪避,在她落地时出手攻击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跑到她身下,攻击她露出的背后。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "upwards-slash", "name": "Upwards Slash", - "move_type": "unblockable", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "guard_break", "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.", - "common_mistakes": null + "counter_zh": "可以弹刀,但时机要求非常精确。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "她开始旋转时就跑开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "利用落地抵消空中攻击的硬直,然后跳开拉到中距离。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] } @@ -984,168 +1827,424 @@ "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.", - "common_mistakes": null + "counter_zh": "跳过去。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面闪避,在她落地时出手攻击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跑到她身下,攻击她露出的背后。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "upwards-slash", "name": "Upwards Slash", - "move_type": "unblockable", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "guard_break", "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.", - "common_mistakes": null + "counter_zh": "可以弹刀,但时机要求非常精确。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "利用落地抵消空中攻击的硬直,然后跳开拉到中距离。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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": "She disappears and the area fills with mist, then summons multiple shadow illusions of herself that attack the player. The shadows disappear between attacks, making lock-on difficult. Standing near a wall before the shadows spawn limits them to one direction.", + "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. Alternatively, grapple up to the tallest tree near the temple; when she appears crouched as she casts or ends the illusion, drop down for an aerial Deathblow, which ends this phase immediately. The branch also keeps you out of reach of most of the shadows.", + "counter_zh": "不需要击败影子——她最终会自己结束幻影并回来。也可以用钩绳爬到神社旁最高的树上,等她施放或结束幻影、蹲下现身时跳下来发动空中忍杀,可以直接结束这个阶段。待在树枝上也能躲开大部分影子。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "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, - "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 + "telegraph_zh": null, + "counter": "Jump over it.", + "counter_zh": "跳过去。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "id": "upwards-slash", + "name": "Upwards Slash", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "guard_break", + "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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跳过去。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面闪避,在她落地时出手攻击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "跑到她身下,攻击她露出的背后。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "upwards-slash", "name": "Upwards Slash", - "move_type": "unblockable", + "name_zh": "上挑斩", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "guard_break", "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.", - "common_mistakes": null + "counter_zh": "可以弹刀,但时机要求非常精确。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "利用落地抵消空中攻击的硬直,然后跳开拉到中距离。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "她开始旋转时就跑开,或者在她起手时用爆竹打断。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "她使用这招时向侧面闪避或跳起踩她,然后反击——这招收招硬直很长。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] } @@ -1157,162 +2256,309 @@ "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", - "move_type": "status", - "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." - }, { "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.", - "common_mistakes": null + "counter_zh": "弹开或闪避手里剑。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "根据自己的躯干状况选择弹刀或闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹刀或后撤。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向斜前方闪避穿过空翻斩,就能获得攻击 3–4 次的机会,或者打出两次秘传·不死斩。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面躲开手里剑,然后弹刀或后撤。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "看到黑烟就跑开,或者主动闪避贴近。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "id": "mikiri-counter", + "name": "Mikiri Counter", + "name_zh": "识破反击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "counter", + "description": "When attacked with a thrust - including Shadowrush, Shadowfall or the Loaded Spear - he steps on the weapon, leaps behind Wolf and cuts his back, then plunges his sword into Wolf's chest.", + "description_zh": "玩家使用突刺类攻击时(包括绝技·巨型忍者突刺、秘传·巨型忍者落杀和机关长枪),他会踩住武器,跃到狼的身后斩击背部,再把刀刺进狼的胸口。", + "telegraph": null, + "telegraph_zh": null, "counter": null, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Great_Shinobi_-_Owl" } ] }, { "phase_number": 2, "name": "Phase 2", + "name_zh": "第二阶段", "moves": [ - { - "id": "shinobi-charm", - "name": "Shinobi Charm", - "move_type": "status", - "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." - }, { "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.", - "common_mistakes": null + "counter_zh": "闪避并远离中毒区域。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "根据自己的躯干状况选择弹刀或闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹刀或后撤。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向斜前方闪避穿过空翻斩,就能获得攻击 3–4 次的机会,或者打出两次秘传·不死斩。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面躲开手里剑,然后弹刀或后撤。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "看到黑烟就跑开,或者主动闪避贴近。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "id": "mikiri-counter", + "name": "Mikiri Counter", + "name_zh": "识破反击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "counter", + "description": "When attacked with a thrust - including Shadowrush, Shadowfall or the Loaded Spear - he steps on the weapon, leaps behind Wolf and cuts his back, then plunges his sword into Wolf's chest.", + "description_zh": "玩家使用突刺类攻击时(包括绝技·巨型忍者突刺、秘传·巨型忍者落杀和机关长枪),他会踩住武器,跃到狼的身后斩击背部,再把刀刺进狼的胸口。", + "telegraph": null, + "telegraph_zh": null, "counter": null, - "common_mistakes": null + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Great_Shinobi_-_Owl" } ] } @@ -1324,402 +2570,881 @@ "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": "Phase 1 (Genichiro, Way of Tomoe)", + "name_zh": "第一阶段(巴流 苇名弦一郎)", + "moves": [ + { + "id": "tomoe-mortal-blade-slash", + "name": "Black Mortal Blade Slash", + "name_zh": "黑色不死斩斩击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "ranged", + "description": "Channels briefly, then unleashes a wide black slash with the Mortal Blade. He opens the fight with it and repeats it occasionally, sometimes twice in a row when you are close.", + "description_zh": "短暂蓄力后,用不死斩挥出一道宽大的黑色斩击。他总是以这招开场,之后也会不时使用;离得近时可能连续挥两次。", + "telegraph": "Channels for about two seconds before the slash.", + "telegraph_zh": "挥斩前会蓄力约两秒。", + "counter": "Dodge behind him during the first swing to get free hits, which staggers his second swing.", + "counter_zh": "在第一次挥斩时闪避到他身后,可以白打几下,并打断他的第二次挥斩。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Isshin,_the_Sword_Saint" + }, + { + "id": "tomoe-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. May follow up with a Perilous Grab.", + "description_zh": "向前扑刺,突刺距离很长。之后可能接危·投技。", + "telegraph": "Perilous Attack kanji.", + "telegraph_zh": "“危”字提示。", + "counter": "Mikiri Counter.", + "counter_zh": "识破。", + "common_mistakes": "Using an item - he answers it with this thrust.", + "common_mistakes_zh": "使用道具——他会用这记突刺回应。", + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Isshin,_the_Sword_Saint" + }, + { + "id": "tomoe-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, followed by a delayed Perilous Thrust. He may use it after any combo.", + "description_zh": "致命的七连击,最后一击能破防,之后还有一记延迟的危·突刺。他可能在任何连击之后接这一招。", + "telegraph": null, + "telegraph_zh": null, + "counter": null, + "counter_zh": null, + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Isshin,_the_Sword_Saint" + }, + { + "id": "tomoe-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, then one of three follow-ups: a Perilous Thrust, a Perilous Sweep, or a quick upward cut.", + "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, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Isshin,_the_Sword_Saint" + }, + { + "id": "tomoe-perilous-grab", + "name": "Perilous Grab", + "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, + "common_mistakes_zh": null, + "source_name": "Fandom Sekiro Wiki", + "source_url": "https://sekiro-shadows-die-twice.fandom.com/wiki/Isshin,_the_Sword_Saint" + } + ] + }, + { + "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.", - "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage." + "counter_zh": "弹开第一道剑气,然后闪避第二道。", + "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", + "common_mistakes_zh": "防御剑气而不是弹开——防御仍然会受到伤害。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "连续按两次弹刀,中间不要停顿;或者跑远完全躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开第一击,然后跳起踩他。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", - "common_mistakes": null + "telegraph_zh": "举刀摆出架势。", + "counter": "Dodge or block; the attack can also be sidestepped. Deflecting the first strike staggers him, but he follows up with a force-wave attack, so dodging or blocking is safer.", + "counter_zh": "闪避或防御;也可以向侧面躲开。弹开第一击会让他硬直,但他接着会发动一记剑气类攻击,所以闪避或防御更稳妥。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "保持足够的距离,以便及时反应。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "等待,在他开始挥动手臂时立刻弹刀;或者冲刺跳向他,再踩他的头跳起。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "逐一弹开;他砍完后会露出破绽,可以快速攻击两下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破——看到预兆后很容易反应。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面跑动躲开,可以白打几下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "在它发动前向后冲刺。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] }, { - "phase_number": 2, - "name": "Phase 2", + "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.", - "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage." + "counter_zh": "弹开第一道剑气,然后闪避第二道。", + "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", + "common_mistakes_zh": "防御剑气而不是弹开——防御仍然会受到伤害。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "连续按两次弹刀,中间不要停顿;或者跑远完全躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开第一击,然后跳起踩他。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", - "common_mistakes": null + "telegraph_zh": "举刀摆出架势。", + "counter": "Dodge or block; the attack can also be sidestepped. Deflecting the first strike staggers him, but he follows up with a force-wave attack, so dodging or blocking is safer.", + "counter_zh": "闪避或防御;也可以向侧面躲开。弹开第一击会让他硬直,但他接着会发动一记剑气类攻击,所以闪避或防御更稳妥。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "保持足够的距离,以便及时反应。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "等待,在他开始挥动手臂时立刻弹刀;或者冲刺跳向他,再踩他的头跳起。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "逐一弹开;他砍完后会露出破绽,可以快速攻击两下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破——看到预兆后很容易反应。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面跑动躲开,可以白打几下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "在它发动前向后冲刺。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "如果没有正确弹开,剑气会穿透防御造成伤害,不过这招很容易闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "leaping-slam", "name": "Leaping Slam", - "move_type": "unblockable", + "name_zh": "跳跃砸击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "overhead", "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.", - "common_mistakes": null + "counter_zh": "弹开这一砸,然后反击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹刀,或者迅速向前闪避白打一下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture." + "counter_zh": "学会弹开子弹,然后识破接下来的突刺;如果是横扫,就跳起攻击。只防御子弹会受到大量躯干伤害。", + "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture.", + "common_mistakes_zh": "漏掉稍有延迟的最后一发子弹,可能会被打破躯干。", + "source_name": null, + "source_url": null } ] }, { - "phase_number": 3, - "name": "Phase 3", + "phase_number": 4, + "name": "Phase 4", + "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.", - "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage." + "counter_zh": "弹开第一道剑气,然后闪避第二道。", + "common_mistakes": "Blocking the waves instead of deflecting them - blocked waves still leak damage.", + "common_mistakes_zh": "防御剑气而不是弹开——防御仍然会受到伤害。", + "source_name": null, + "source_url": null }, { "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.", - "common_mistakes": null + "counter_zh": "连续按两次弹刀,中间不要停顿;或者跑远完全躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹开第一击,然后跳起踩他。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "counter": "Dodge or block; the attack can also be sidestepped. Deflecting triggers a stagger.", - "common_mistakes": null + "telegraph_zh": "举刀摆出架势。", + "counter": "Dodge or block; the attack can also be sidestepped. Deflecting the first strike staggers him, but he follows up with a force-wave attack, so dodging or blocking is safer.", + "counter_zh": "闪避或防御;也可以向侧面躲开。弹开第一击会让他硬直,但他接着会发动一记剑气类攻击,所以闪避或防御更稳妥。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "保持足够的距离,以便及时反应。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "等待,在他开始挥动手臂时立刻弹刀;或者冲刺跳向他,再踩他的头跳起。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "逐一弹开;他砍完后会露出破绽,可以快速攻击两下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "识破——看到预兆后很容易反应。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "向侧面跑动躲开,可以白打几下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "在它发动前向后冲刺。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "如果没有正确弹开,剑气会穿透防御造成伤害,不过这招很容易闪避。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "leaping-slam", "name": "Leaping Slam", - "move_type": "unblockable", + "name_zh": "跳跃砸击", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "overhead", "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.", - "common_mistakes": null + "counter_zh": "弹开这一砸,然后反击。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": null + "counter_zh": "弹刀,或者迅速向前闪避白打一下。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": 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.", - "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture." + "counter_zh": "学会弹开子弹,然后识破接下来的突刺;如果是横扫,就跳起攻击。只防御子弹会受到大量躯干伤害。", + "common_mistakes": "Missing the slightly delayed last bullet, which can break your posture.", + "common_mistakes_zh": "漏掉稍有延迟的最后一发子弹,可能会被打破躯干。", + "source_name": null, + "source_url": null }, { "id": "lightning-slash", "name": "Lightning Slash", - "move_type": "unblockable", + "name_zh": "雷电斩", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "lightning", "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.", - "common_mistakes": null + "counter_zh": "跳到空中,用雷反、雾鸦或秘传·樱舞把雷电返还给他,让他硬直。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "lightning-overhead", "name": "Lightning-Enhanced Overhead Attacks", - "move_type": "overhead", + "name_zh": "雷电强化下劈", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "lightning", "description": "Large overhead attacks with added lightning damage.", + "description_zh": "附带雷电伤害的大幅度下劈。", "telegraph": null, + "telegraph_zh": null, "counter": "Can be deflected, though the timing is difficult.", - "common_mistakes": null + "counter_zh": "可以弹刀,但时机很难把握。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null }, { "id": "lightning-spear-sweep", "name": "Large Sweep with Lightning-Covered Spear", - "move_type": "sweep", + "name_zh": "雷枪大横扫", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "lightning", "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.", - "common_mistakes": null - }, - { - "id": "distance-slash-lightning", - "name": "Distance Slash (Lightning Variant)", - "move_type": "combo", - "description": "More of a distance slash than the vertical Phase 1 wind attack.", + "counter_zh": "他使用这招时贴近并跳起,把雷电直接返还给他,能造成大量体力和躯干伤害,还能白打一轮。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null + }, + { + "id": "distance-slash-phase-3", + "name": "Distance Slash (Phase 3)", + "name_zh": "远距离斩击(第三阶段)", + "name_zh_source": "translation", + "name_zh_source_url": null, + "move_type": "ranged", + "description": "Similar to his Phase 1 long-range wind attack, but a distance slash rather than a vertical one.", + "description_zh": "和第一阶段的远距离风压攻击类似,但不是垂直的,更像一记远距离斩击。", "telegraph": null, + "telegraph_zh": null, "counter": "Similar avoidance strategy to the wind attacks - dodge by running sideways.", - "common_mistakes": null + "counter_zh": "躲法和风压攻击类似——向侧面跑动躲开。", + "common_mistakes": null, + "common_mistakes_zh": null, + "source_name": null, + "source_url": null } ] } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 146abe4..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 @@ -49,7 +50,29 @@ def seeded_session(session): @pytest.fixture() -def client(seeded_session): +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.""" 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..f245c51 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: @@ -50,6 +50,71 @@ 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_way_of_tomoe_migration_moves_isshin_attempts_up_one_phase(engine, database_url): + config = alembic_config(database_url) + command.downgrade(config, "6849ddf003e3") # the revision before the shift + try: + with engine.begin() as conn: + game_id = conn.scalar(text("INSERT INTO games (slug, name) VALUES ('mig', 'Mig') RETURNING id")) + ids = {} + for slug in ("isshin-sword-saint", "mig-other-boss"): + ids[slug] = conn.scalar( + text("INSERT INTO bosses (game_id, slug, name, location) VALUES (:g, :s, 'B', 'L') RETURNING id"), + {"g": game_id, "s": slug}, + ) + conn.execute( + text("INSERT INTO attempts (boss_id, result, phase_reached) VALUES (:b, 'failed', 2)"), + {"b": ids[slug]}, + ) + + command.upgrade(config, "head") + + with engine.connect() as conn: + phases = dict(conn.execute(text("SELECT b.slug, a.phase_reached FROM attempts a JOIN bosses b ON b.id = a.boss_id")).all()) + assert phases == {"isshin-sword-saint": 3, "mig-other-boss": 2} + 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 game_id IN (SELECT id FROM games WHERE slug = 'mig'))")) + conn.execute(text("DELETE FROM bosses WHERE game_id IN (SELECT id FROM games WHERE slug = 'mig')")) + conn.execute(text("DELETE FROM games WHERE slug = 'mig'")) + + +def test_move_source_name_and_url_go_together(session): + boss = make_boss(session) + session.add(Move(boss=boss, slug="half-source", name="X", move_type="thrust", source_name="Somewhere")) + with pytest.raises(IntegrityError): + session.flush() + + 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_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..0103218 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,149 @@ +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 + + +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_bosses.py b/backend/tests/test_bosses.py index 0a8c968..98a47b9 100644 --- a/backend/tests/test_bosses.py +++ b/backend/tests/test_bosses.py @@ -90,7 +90,7 @@ def test_get_fifth_boss_has_a_single_phase(client): assert "whirlwind-attack" in move_ids -def test_get_sixth_boss_has_a_gimmick_middle_phase(client): +def test_get_sixth_boss_adds_illusions_in_the_middle_phase(client): resp = client.get("/api/bosses/true-corrupted-monk") assert resp.status_code == 200 data = resp.json() @@ -101,8 +101,8 @@ def test_get_sixth_boss_has_a_gimmick_middle_phase(client): phase_2_move_ids = {m["id"] for m in data["phases"][1]["moves"]} phase_3_move_ids = {m["id"] for m in data["phases"][2]["moves"]} - # Phase 2 is a short illusion-summoning gimmick, not an extension of Phase 1. - assert phase_2_move_ids == {"illusion-summoning"} + # Phase 2 keeps her Phase 1 moveset and adds the illusion summoning. + assert phase_2_move_ids == phase_1_move_ids | {"illusion-summoning"} # Phase 1 doesn't have the whirlwind attack yet; it's regained in Phase 3 # along with the new centipede moves. assert "whirlwind-attack" not in phase_1_move_ids @@ -127,21 +127,23 @@ def test_get_seventh_boss_replaces_backflip_with_the_poison_variant_in_phase_2(c assert phase_1_move_ids - {"backflip"} <= phase_2_move_ids -def test_get_eighth_boss_accumulates_moves_across_all_three_phases(client): +def test_get_eighth_boss_starts_with_genichiro_then_accumulates_isshin_moves(client): resp = client.get("/api/bosses/isshin-sword-saint") assert resp.status_code == 200 data = resp.json() assert data["name"] == "Isshin, the Sword Saint" - assert len(data["phases"]) == 3 + assert len(data["phases"]) == 4 - phase_1_move_ids = {m["id"] for m in data["phases"][0]["moves"]} - phase_2_move_ids = {m["id"] for m in data["phases"][1]["moves"]} - phase_3_move_ids = {m["id"] for m in data["phases"][2]["moves"]} + tomoe, isshin_1, isshin_2, isshin_3 = ({m["id"] for m in p["moves"]} for p in data["phases"]) - assert phase_1_move_ids <= phase_2_move_ids <= phase_3_move_ids - assert "quad-shot" in phase_2_move_ids - assert "lightning-slash" in phase_3_move_ids - assert "lightning-slash" not in phase_2_move_ids + # Phase 1 is Genichiro, Way of Tomoe; Isshin's own phases follow. + assert "Way of Tomoe" in data["phases"][0]["name"] + assert "tomoe-mortal-blade-slash" in tomoe + assert not tomoe & isshin_1 + assert isshin_1 <= isshin_2 <= isshin_3 + assert "quad-shot" in isshin_2 + assert "lightning-slash" in isshin_3 + assert "lightning-slash" not in isshin_2 def test_get_boss_not_found(client): @@ -161,10 +163,20 @@ def test_boss_detail_exposes_source_and_move_metadata(client): assert data["source_name"] == "Fextralife Sekiro Wiki" assert data["source_url"].endswith("/Isshin,+the+Sword+Saint") - dragon_flash = next(m for m in data["phases"][0]["moves"] if m["id"] == "dragon-flash") + dragon_flash = next(m for m in data["phases"][1]["moves"] if m["id"] == "dragon-flash") assert "glint" in dragon_flash["telegraph"] assert dragon_flash["common_mistakes"] - assert "source_url" not in dragon_flash + # Dragon Flash comes from the boss's own source, so it has no move-level source. + assert dragon_flash["source_name"] is None and dragon_flash["source_url"] is None + + +def test_a_move_from_another_page_carries_its_own_source(client): + data = client.get("/api/bosses/owl-father").json() + mikiri = next(m for m in data["phases"][0]["moves"] if m["id"] == "mikiri-counter") + + assert data["source_name"] == "Fextralife Sekiro Wiki" + assert mikiri["source_name"] == "Fandom Sekiro Wiki" + assert mikiri["source_url"].startswith("https://sekiro-shadows-die-twice.fandom.com/") def test_every_boss_has_a_source(client): diff --git a/backend/tests/test_move_chinese_names.py b/backend/tests/test_move_chinese_names.py new file mode 100644 index 0000000..10def39 --- /dev/null +++ b/backend/tests/test_move_chinese_names.py @@ -0,0 +1,120 @@ +"""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 + + +# --- 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 0d4582d..2760963 100644 --- a/backend/tests/test_sekiro_analytics.py +++ b/backend/tests/test_sekiro_analytics.py @@ -93,33 +93,39 @@ 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, "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) -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 +134,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 1bb0c50..26e0ec4 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,72 @@ describe("frontend calling the real backend", () => { failure_by_move: {}, }); }); + + 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"); + + 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); + }); + + 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/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..4877f3b --- /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", preferred_language: null }); + + const user = await login({ username: "wolf", password: "kusabimaru" }); + + 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" }) }) + ); + }); + + 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..631622f --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,28 @@ +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" }); +} + +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 { + return await apiRequest("/auth/me"); + } catch (err) { + if (err instanceof ApiError && err.status === 401) return null; + throw err; + } +} 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/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..50c844f --- /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", preferred_language: null }); + + 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", preferred_language: null }); + 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", preferred_language: null }); + 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..71b7f5e --- /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", 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 { + 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/AnalyticsPanel.test.tsx b/frontend/src/components/AnalyticsPanel.test.tsx index 5e57774..3b02c9b 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 }, + { 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, source_name: null, source_url: 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 }, + { 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, source_name: null, source_url: 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 10a2b8f..58bcff0 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 }, + { 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, source_name: null, source_url: 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 new file mode 100644 index 0000000..1d81007 --- /dev/null +++ b/frontend/src/components/BossList.tsx @@ -0,0 +1,51 @@ +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"); + + 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

    {t("bossList.loading")}

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

    {t("bossList.error")}

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

      {bossName(boss, language)}

      +

      {bossLocation(boss, language)}

      + + {t("bossList.view")} + +
    • + ))} +
    + ); +} diff --git a/frontend/src/components/MovesetReference.test.tsx b/frontend/src/components/MovesetReference.test.tsx index 83be176..3ea5eb2 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", @@ -24,6 +27,15 @@ 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", + description_zh: "向下斩击放出一道剑气。", + telegraph_zh: "刀柄上闪过一道光。", + counter_zh: "弹开第一道剑气。", + common_mistakes_zh: "防御剑气而不是弹开。", + source_name: null, + source_url: null, }, { id: "thrust", @@ -33,6 +45,15 @@ const boss: Boss = { telegraph: null, counter: null, common_mistakes: null, + name_zh: "突刺", + name_zh_source: "translation", + name_zh_source_url: null, + description_zh: null, + telegraph_zh: null, + counter_zh: null, + common_mistakes_zh: null, + source_name: null, + source_url: null, }, ], }, @@ -52,6 +73,8 @@ describe("MovesetReference", () => { render(); 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"); }); @@ -66,4 +89,60 @@ describe("MovesetReference", () => { expect(screen.queryByText(/source:/i)).not.toBeInTheDocument(); }); + + it("shows only English in English mode", () => { + render(); + + expect(screen.getByText("Dragon Flash")).toBeInTheDocument(); + expect(screen.queryByText("秘传·龙闪")).not.toBeInTheDocument(); + expect(screen.queryByText(/向下斩击/)).not.toBeInTheDocument(); + }); + + 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(); + }); + + it("links a move's own source when it differs from the boss's", () => { + const withMoveSource: Boss = { + ...boss, + phases: [ + { + ...boss.phases[0], + moves: [ + { + ...boss.phases[0].moves[1], + source_name: "Fandom Sekiro Wiki", + source_url: "https://sekiro-shadows-die-twice.fandom.com/wiki/Example", + }, + ], + }, + ], + }; + render(); + + expect(screen.getByText("Source for this move:")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Fandom Sekiro Wiki" })).toHaveAttribute( + "href", + "https://sekiro-shadows-die-twice.fandom.com/wiki/Example" + ); + }); }); diff --git a/frontend/src/components/MovesetReference.tsx b/frontend/src/components/MovesetReference.tsx index 94a4ae2..8901866 100644 --- a/frontend/src/components/MovesetReference.tsx +++ b/frontend/src/components/MovesetReference.tsx @@ -1,30 +1,66 @@ -import type { Boss } from "../types"; +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 })}

      } + {move.source_url && ( +

      + {t("moveset.moveSource")}{" "} + + {move.source_name ?? move.source_url} + +

      + )} +
    • + ); + })}
    ))} {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 new file mode 100644 index 0000000..0a911d5 --- /dev/null +++ b/frontend/src/components/NavBar.tsx @@ -0,0 +1,54 @@ +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() { + await logout().catch(() => undefined); + navigate("/"); + } + + const otherLanguage = language === "en" ? "zh" : "en"; + + return ( +

    + +
    + + {status === "ready" && + (user ? ( + <> + {user.username} + + + ) : ( + <> + {t("nav.logIn")} + + {t("nav.register")} + + + ))} +
    +
    + ); +} diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx new file mode 100644 index 0000000..7b69466 --- /dev/null +++ b/frontend/src/components/PasswordInput.tsx @@ -0,0 +1,63 @@ +import { useId, useState } from "react"; +import { useLanguage } from "../i18n/language"; + +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 { t } = useLanguage(); + const id = useId(); + const [visible, setVisible] = useState(false); + const toggleLabel = visible ? t("password.hide") : t("password.show"); + + return ( +
    + +
    + onChange(e.target.value)} + required + /> + +
    +
    + ); +} 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 83dc722..22ec52a 100644 --- a/frontend/src/components/RecordAttemptForm.test.tsx +++ b/frontend/src/components/RecordAttemptForm.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import RecordAttemptForm from "./RecordAttemptForm"; +import { withLanguage } from "../i18n/testLanguage"; import * as attemptsApi from "../api/attempts"; import { ApiError } from "../api/client"; import type { Attempt, Boss } from "../types"; @@ -13,19 +14,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 }, + { id: "thrust-attack", name: "Thrust Attack", move_type: "thrust", description: "A quick lunge.", telegraph: null, counter: null, common_mistakes: null, name_zh: "突刺攻击", name_zh_source: "translation", name_zh_source_url: null, description_zh: "快速突进一刺。", telegraph_zh: null, counter_zh: null, common_mistakes_zh: null, source_name: null, source_url: 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 }, + { 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, source_name: null, source_url: null }, ], }, ], @@ -143,4 +147,40 @@ describe("RecordAttemptForm", () => { expect(await screen.findByRole("alert")).toHaveTextContent("Phase 5 does not exist"); }); + + it("describes each move on hover and shows the chosen move's description", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("option", { name: "Thrust Attack" })).toHaveAttribute("title", "A quick lunge."); + expect(screen.queryByText("A quick lunge.")).not.toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText(/what ended this attempt/i), "thrust-attack"); + + expect(screen.getByText("A quick lunge.")).toBeInTheDocument(); + expect(screen.getByLabelText(/what ended this attempt/i)).toHaveAccessibleDescription("A quick lunge."); + }); + + it("shows no description for a move without one, or for Other", async () => { + const user = userEvent.setup(); + render(); + const cause = screen.getByLabelText(/what ended this attempt/i); + + await user.selectOptions(screen.getByLabelText(/phase reached/i), "2"); + await user.selectOptions(cause, "floating-passage"); + expect(screen.getByRole("option", { name: "Floating Passage" })).not.toHaveAttribute("title"); + expect(document.querySelector(".move-description-hint")).toBeNull(); + + await user.selectOptions(cause, "__other__"); + expect(document.querySelector(".move-description-hint")).toBeNull(); + }); + + it("describes moves in Chinese in Chinese mode", async () => { + const user = userEvent.setup(); + render(withLanguage(, "zh")); + + expect(screen.getByRole("option", { name: "突刺攻击" })).toHaveAttribute("title", "快速突进一刺。"); + await user.selectOptions(screen.getByLabelText("这次是被什么打败的?"), "thrust-attack"); + expect(screen.getByText("快速突进一刺。")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/RecordAttemptForm.tsx b/frontend/src/components/RecordAttemptForm.tsx index e9685fb..bf6569d 100644 --- a/frontend/src/components/RecordAttemptForm.tsx +++ b/frontend/src/components/RecordAttemptForm.tsx @@ -1,8 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import type { FormEvent } from "react"; import { createAttempt } from "../api/attempts"; import { ApiError } from "../api/client"; import type { AttemptResult, Boss, CreateAttemptRequest } from "../types"; +import { bossName, moveName, moveText, phaseName } from "../i18n/content"; +import { useLanguage } from "../i18n/language"; const OTHER = "__other__"; const NOT_SURE = "__not_sure__"; @@ -14,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(""); @@ -22,6 +25,9 @@ export default function RecordAttemptForm({ boss, onSuccess, onCancel }: RecordA const [error, setError] = useState(null); const movesForPhase = boss.phases.find((p) => p.phase_number === phaseReached)?.moves ?? []; + const selectedMove = movesForPhase.find((m) => m.id === failureChoice); + const selectedDescription = selectedMove ? moveText(selectedMove, "description", language) : null; + const descriptionId = useId(); useEffect(() => { if (failureChoice === "" || failureChoice === OTHER || failureChoice === NOT_SURE) return; @@ -54,16 +60,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}