Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ Completed:
1. Accounts and authentication
2. User-owned attempts
3. Per-user analytics and isolation
4. Bilingual interface

Remaining:

4. Bilingual interface
5. Existing-content cleanup
6. V3 hardening and release

Expand Down Expand Up @@ -371,7 +371,7 @@ Do not change the mathematical definitions of V2 analytics solely because user i

---

# 11. V3 Milestone 4 — Bilingual Interface
# 11. V3 Milestone 4 — Bilingual Interface ✅

Implement English / Chinese product support.

Expand Down Expand Up @@ -444,6 +444,20 @@ unless there is a deliberate product reason.

---

## Delivered

- 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

The user reviewed all Chinese content.

---

# 12. V3 Milestone 5 — Existing-Content Cleanup

This milestone has changed significantly from the old roadmap.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ V3 turns the single-user local app into a foundation that several players can us
* ✅ **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.
* ✅ **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:** correcting the current boss data rather than adding more bosses from wikis.
* **Release `v3.0.0`.**

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ V3 把单用户的本地应用,变成可以供多名玩家使用、每人数
* ✅ **账号与认证:** 注册、登录、登出;密码用 argon2 哈希保存;登录状态是存在 httpOnly cookie 里的服务器端会话。不登录也能浏览 Boss 资料。
* ✅ **攻略记录归属用户:** 每条记录都属于记录它的玩家。有账号系统之前的旧记录都保留着,可以用 `scripts.claim_attempts` 认领。
* ✅ **按用户的分析:** 攻略历史、Boss 分析、进度图和只狼总览都只统计你自己的记录。
* 🚧 **中英文界面:** 可以切换语言,每种语言只显示该语言的文字。
* ✅ **中英文界面:** 可以切换语言,每种语言只显示该语言的文字。登录用户的选择保存在账号里,访客的选择保存在浏览器里。大部分中文招式名和说明译自英文资料。
* **清理现有内容:** 修正现有的 Boss 数据,而不是继续从 Wiki 补充更多 Boss。
* **发布 `v3.0.0`。**

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,11 @@ A user must never read, modify, or influence another user's attempt analytics.

---

## Milestone 4 — Bilingual Product Interface
## Milestone 4 — Bilingual Product Interface ✅

Add English / Chinese language support for the existing product.

Requirements:
Delivered:

- language switch in the navigation
- logged-in users store their language preference in their account
Expand Down
46 changes: 46 additions & 0 deletions backend/alembic/versions/01e9ec08d5ac_add_move_chinese_names.py
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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')
30 changes: 29 additions & 1 deletion backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Boss(Base):
name: Mapped[str] = mapped_column(String(200))
name_zh: Mapped[str | None] = mapped_column(String(200))
location: Mapped[str] = mapped_column(String(200))
location_zh: Mapped[str | None] = mapped_column(String(200))
# Where this boss's phase and move data came from; moves share their boss's source.
source_name: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
Expand All @@ -54,6 +55,7 @@ class BossPhase(Base):
boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE"))
phase_number: Mapped[int] = mapped_column(Integer)
name: Mapped[str] = mapped_column(String(100))
name_zh: Mapped[str | None] = mapped_column(String(100))

boss: Mapped[Boss] = relationship(back_populates="phases")
move_links: Mapped[list["PhaseMove"]] = relationship(
Expand All @@ -63,7 +65,18 @@ class BossPhase(Base):

class Move(Base):
__tablename__ = "moves"
__table_args__ = (UniqueConstraint("boss_id", "slug"),)
__table_args__ = (
UniqueConstraint("boss_id", "slug"),
CheckConstraint(
"name_zh_source IS NULL OR name_zh_source IN ('wiki', 'translation')",
name="ck_moves_name_zh_source",
),
CheckConstraint("(name_zh IS NULL) = (name_zh_source IS NULL)", name="ck_moves_name_zh_has_source"),
# A name taken from a Chinese wiki must say which page; a translation has no page.
CheckConstraint(
"(name_zh_source = 'wiki') = (name_zh_source_url IS NOT NULL)", name="ck_moves_name_zh_source_url"
),
)

id: Mapped[int] = mapped_column(primary_key=True)
boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE"))
Expand All @@ -74,6 +87,16 @@ class Move(Base):
telegraph: Mapped[str | None] = mapped_column(Text)
counter: Mapped[str | None] = mapped_column(Text)
common_mistakes: Mapped[str | None] = mapped_column(Text)
# Chinese versions of the text fields above: translations of the sourced English text.
description_zh: Mapped[str | None] = mapped_column(Text)
telegraph_zh: Mapped[str | None] = mapped_column(Text)
counter_zh: Mapped[str | None] = mapped_column(Text)
common_mistakes_zh: Mapped[str | None] = mapped_column(Text)
# Chinese name: either the name used by a Chinese wiki ('wiki', with the page in
# name_zh_source_url) or a translation ('translation'), never presented as official.
name_zh: Mapped[str | None] = mapped_column(String(200))
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")
Expand Down Expand Up @@ -133,12 +156,17 @@ class User(Base):
__tablename__ = "users"
__table_args__ = (
CheckConstraint("username = lower(username)", name="ck_users_username_lowercase"),
CheckConstraint(
"preferred_language IS NULL OR preferred_language IN ('en', 'zh')", name="ck_users_preferred_language"
),
)

id: Mapped[int] = mapped_column(primary_key=True)
# Stored lowercase so usernames are unique regardless of case.
username: Mapped[str] = mapped_column(String(30), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
# Interface language chosen in the app; null until the user picks one.
preferred_language: Mapped[str | None] = mapped_column(String(5))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

sessions: Mapped[list["UserSession"]] = relationship(back_populates="user", cascade="all, delete-orphan")
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
9 changes: 9 additions & 0 deletions backend/app/models/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
from typing import Literal

from pydantic import BaseModel, Field, field_validator

Expand Down Expand Up @@ -28,6 +29,14 @@ class LoginRequest(BaseModel):
password: str = Field(max_length=128)


Language = Literal["en", "zh"]


class User(BaseModel):
id: str
username: str
preferred_language: Language | None = None


class UpdateUserRequest(BaseModel):
preferred_language: Language
24 changes: 23 additions & 1 deletion backend/app/models/boss.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from pydantic import BaseModel
from typing import Literal

from pydantic import BaseModel, model_validator


class BossMove(BaseModel):
Expand All @@ -9,11 +11,29 @@ 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
# "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] = []


Expand All @@ -23,6 +43,7 @@ class Boss(BaseModel):
name_zh: str | None = None
game: str
location: str
location_zh: str | None = None
phases: list[BossPhase]
source_name: str | None = None
source_url: str | None = None
Expand All @@ -33,3 +54,4 @@ class BossSummary(BaseModel):
name: str
name_zh: str | None = None
location: str
location_zh: str | None = None
11 changes: 9 additions & 2 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from app.auth import SESSION_COOKIE, clear_session_cookie, get_current_user, set_session_cookie
from app.db import models
from app.db.session import get_db
from app.models.auth import LoginRequest, RegisterRequest, User
from app.models.auth import LoginRequest, RegisterRequest, UpdateUserRequest, User
from app.services import auth_service

router = APIRouter(prefix="/api/auth", tags=["auth"])


def _to_response(user: models.User) -> User:
return User(id=str(user.id), username=user.username)
return User(id=str(user.id), username=user.username, preferred_language=user.preferred_language)


@router.post("/register", response_model=User, status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -48,3 +48,10 @@ def logout(
@router.get("/me", response_model=User)
def me(user: models.User = Depends(get_current_user)):
return _to_response(user)


@router.patch("/me", response_model=User)
def update_me(
req: UpdateUserRequest, user: models.User = Depends(get_current_user), db: Session = Depends(get_db)
):
return _to_response(auth_service.set_preferred_language(db, user, req.preferred_language))
Loading
Loading