diff --git a/.beans/nuzlocke-tracker-i08l--implement-pinwheel-clause-support.md b/.beans/nuzlocke-tracker-i08l--implement-pinwheel-clause-support.md new file mode 100644 index 0000000..afdff1d --- /dev/null +++ b/.beans/nuzlocke-tracker-i08l--implement-pinwheel-clause-support.md @@ -0,0 +1,25 @@ +--- +# nuzlocke-tracker-i08l +title: Implement Pinwheel Clause support +status: completed +type: feature +priority: normal +created_at: 2026-02-07T19:18:34Z +updated_at: 2026-02-07T19:21:45Z +--- + +Add pinwheel_zone column to routes, pinwheelClause toggle to NuzlockeRules, zone-aware encounter locking on frontend and backend. + +## Checklist +- [x] Alembic migration for pinwheel_zone column +- [x] SQLAlchemy model update +- [x] Pydantic schema updates +- [x] Route list API helper update +- [x] Encounter creation API zone-aware sibling check +- [x] Seed loader update +- [x] Seed data for Pinwheel Forest zones +- [x] NuzlockeRules per-run toggle +- [x] Frontend types (game.ts, admin.ts) +- [x] Admin route form pinwheelZone input +- [x] Encounter page zone-aware locking, counts, and filtering +- [x] getZoneEncounters helper \ No newline at end of file diff --git a/backend/src/app/alembic/versions/a1b2c3d4e5f7_add_pinwheel_clause_support.py b/backend/src/app/alembic/versions/a1b2c3d4e5f7_add_pinwheel_clause_support.py new file mode 100644 index 0000000..7fbbd92 --- /dev/null +++ b/backend/src/app/alembic/versions/a1b2c3d4e5f7_add_pinwheel_clause_support.py @@ -0,0 +1,29 @@ +"""add pinwheel clause support + +Revision ID: a1b2c3d4e5f7 +Revises: f6a7b8c9d0e1 +Create Date: 2026-02-07 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f7' +down_revision: Union[str, Sequence[str], None] = 'f6a7b8c9d0e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'routes', + sa.Column('pinwheel_zone', sa.SmallInteger(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('routes', 'pinwheel_zone') diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index e40b9a5..dcedd1f 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -50,15 +50,30 @@ async def create_encounter( detail="Cannot create encounter on a parent route. Use a child route instead.", ) - # If this route has a parent, check if any sibling already has an encounter + # If this route has a parent, check if sibling already has an encounter if route.parent_route_id is not None: - # Get all sibling route IDs (routes with same parent, including this one) + # Get all sibling routes (routes with same parent, including this one) siblings_result = await session.execute( - select(Route.id).where(Route.parent_route_id == route.parent_route_id) + select(Route).where(Route.parent_route_id == route.parent_route_id) ) - sibling_ids = [r for r in siblings_result.scalars().all()] + siblings = siblings_result.scalars().all() - # Check if any sibling already has an encounter in this run + # Determine which siblings to check based on pinwheel clause + pinwheel_on = run.rules.get("pinwheelClause", True) if run.rules else True + any_has_zone = any(s.pinwheel_zone is not None for s in siblings) + + if pinwheel_on and any_has_zone: + # Zone-aware: only check siblings in the same zone (null treated as 0) + my_zone = route.pinwheel_zone if route.pinwheel_zone is not None else 0 + sibling_ids = [ + s.id for s in siblings + if (s.pinwheel_zone if s.pinwheel_zone is not None else 0) == my_zone + ] + else: + # No pinwheel clause or no zones defined: all siblings share + sibling_ids = [s.id for s in siblings] + + # Check if any relevant sibling already has an encounter in this run existing_encounter = await session.execute( select(Encounter) .where( diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py index 8013d0e..126374b 100644 --- a/backend/src/app/api/games.py +++ b/backend/src/app/api/games.py @@ -80,6 +80,7 @@ async def list_game_routes( "game_id": route.game_id, "order": route.order, "parent_route_id": route.parent_route_id, + "pinwheel_zone": route.pinwheel_zone, "encounter_methods": methods, } diff --git a/backend/src/app/models/route.py b/backend/src/app/models/route.py index ccc7187..2d89a91 100644 --- a/backend/src/app/models/route.py +++ b/backend/src/app/models/route.py @@ -17,6 +17,7 @@ class Route(Base): parent_route_id: Mapped[int | None] = mapped_column( ForeignKey("routes.id", ondelete="CASCADE"), index=True, default=None ) + pinwheel_zone: Mapped[int | None] = mapped_column(SmallInteger, default=None) game: Mapped["Game"] = relationship(back_populates="routes") route_encounters: Mapped[list["RouteEncounter"]] = relationship( diff --git a/backend/src/app/schemas/game.py b/backend/src/app/schemas/game.py index d3ef9cb..d903549 100644 --- a/backend/src/app/schemas/game.py +++ b/backend/src/app/schemas/game.py @@ -7,6 +7,7 @@ class RouteResponse(CamelModel): game_id: int order: int parent_route_id: int | None = None + pinwheel_zone: int | None = None encounter_methods: list[str] = [] @@ -56,12 +57,14 @@ class RouteCreate(CamelModel): name: str order: int parent_route_id: int | None = None + pinwheel_zone: int | None = None class RouteUpdate(CamelModel): name: str | None = None order: int | None = None parent_route_id: int | None = None + pinwheel_zone: int | None = None class RouteReorderItem(CamelModel): diff --git a/backend/src/app/seeds/data/black-2.json b/backend/src/app/seeds/data/black-2.json index ccb81d7..b00a9dc 100644 --- a/backend/src/app/seeds/data/black-2.json +++ b/backend/src/app/seeds/data/black-2.json @@ -1333,6 +1333,7 @@ { "name": "Pinwheel Forest (Outside)", "order": 23, + "pinwheel_zone": 1, "encounters": [ { "pokeapi_id": 533, @@ -1379,6 +1380,7 @@ { "name": "Pinwheel Forest (Inside)", "order": 24, + "pinwheel_zone": 2, "encounters": [ { "pokeapi_id": 550, diff --git a/backend/src/app/seeds/data/black.json b/backend/src/app/seeds/data/black.json index 200ebef..0d75596 100644 --- a/backend/src/app/seeds/data/black.json +++ b/backend/src/app/seeds/data/black.json @@ -1321,6 +1321,7 @@ { "name": "Pinwheel Forest (Outside)", "order": 25, + "pinwheel_zone": 1, "encounters": [ { "pokeapi_id": 535, @@ -1359,6 +1360,7 @@ { "name": "Pinwheel Forest (Inside)", "order": 26, + "pinwheel_zone": 2, "encounters": [ { "pokeapi_id": 550, diff --git a/backend/src/app/seeds/data/white-2.json b/backend/src/app/seeds/data/white-2.json index 027b942..30e252f 100644 --- a/backend/src/app/seeds/data/white-2.json +++ b/backend/src/app/seeds/data/white-2.json @@ -1333,6 +1333,7 @@ { "name": "Pinwheel Forest (Outside)", "order": 23, + "pinwheel_zone": 1, "encounters": [ { "pokeapi_id": 533, @@ -1379,6 +1380,7 @@ { "name": "Pinwheel Forest (Inside)", "order": 24, + "pinwheel_zone": 2, "encounters": [ { "pokeapi_id": 10016, diff --git a/backend/src/app/seeds/data/white.json b/backend/src/app/seeds/data/white.json index 6eacfe8..60c4aae 100644 --- a/backend/src/app/seeds/data/white.json +++ b/backend/src/app/seeds/data/white.json @@ -1321,6 +1321,7 @@ { "name": "Pinwheel Forest (Outside)", "order": 25, + "pinwheel_zone": 1, "encounters": [ { "pokeapi_id": 535, @@ -1359,6 +1360,7 @@ { "name": "Pinwheel Forest (Inside)", "order": 26, + "pinwheel_zone": 2, "encounters": [ { "pokeapi_id": 10016, diff --git a/backend/src/app/seeds/loader.py b/backend/src/app/seeds/loader.py index b575559..d44dbfe 100644 --- a/backend/src/app/seeds/loader.py +++ b/backend/src/app/seeds/loader.py @@ -109,9 +109,14 @@ async def upsert_routes( game_id=game_id, order=child["order"], parent_route_id=parent_id, + pinwheel_zone=child.get("pinwheel_zone"), ).on_conflict_do_update( constraint="uq_routes_game_name", - set_={"order": child["order"], "parent_route_id": parent_id}, + set_={ + "order": child["order"], + "parent_route_id": parent_id, + "pinwheel_zone": child.get("pinwheel_zone"), + }, ) await session.execute(stmt) diff --git a/frontend/src/components/admin/RouteFormModal.tsx b/frontend/src/components/admin/RouteFormModal.tsx index f63ac42..14035af 100644 --- a/frontend/src/components/admin/RouteFormModal.tsx +++ b/frontend/src/components/admin/RouteFormModal.tsx @@ -13,10 +13,17 @@ interface RouteFormModalProps { export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting }: RouteFormModalProps) { const [name, setName] = useState(route?.name ?? '') const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0)) + const [pinwheelZone, setPinwheelZone] = useState( + route?.pinwheelZone != null ? String(route.pinwheelZone) : '' + ) const handleSubmit = (e: FormEvent) => { e.preventDefault() - onSubmit({ name, order: Number(order) }) + onSubmit({ + name, + order: Number(order), + pinwheelZone: pinwheelZone !== '' ? Number(pinwheelZone) : null, + }) } return ( @@ -47,6 +54,20 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" /> +
+ Routes in the same zone share an encounter when the Pinwheel Clause is active +
+