Add gift clause rule for free gift encounters

When enabled, in-game gift Pokemon (starters, trades, fossils) do not
count against a location's encounter limit. Both a gift encounter and
a regular encounter can coexist on the same route, in any order.

Persists encounter origin on the Encounter model so the backend can
exclude gift encounters from route-lock checks bidirectionally, and the
frontend can split them into a separate display layer that doesn't lock
the route for regular encounters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 21:55:16 +01:00
parent ed1f7ad3d0
commit 18cc116348
10 changed files with 201 additions and 32 deletions

View File

@@ -0,0 +1,29 @@
"""add origin to encounters
Revision ID: i0d1e2f3a4b5
Revises: h9c0d1e2f3a4
Create Date: 2026-02-20 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "i0d1e2f3a4b5"
down_revision: str | Sequence[str] | None = "h9c0d1e2f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"encounters",
sa.Column("origin", sa.String(20), nullable=True),
)
def downgrade() -> None:
op.drop_column("encounters", "origin")

View File

@@ -58,12 +58,13 @@ async def create_encounter(
detail="Cannot create encounter on a parent route. Use a child route instead.",
)
# Shiny clause: shiny encounters bypass the route-lock check
# Shiny/gift clause: certain encounters bypass the route-lock check
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in (
"shed_evolution",
"egg",
"transfer",
gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
skip_route_lock = (
(data.is_shiny and shiny_clause_on)
or (data.origin == "gift" and gift_clause_on)
or data.origin in ("shed_evolution", "egg", "transfer")
)
# If this route has a parent, check if sibling already has an encounter
@@ -93,13 +94,17 @@ async def create_encounter(
# Check if any relevant sibling already has an encounter in this run
# Exclude transfer-target encounters so they don't block the starter
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
existing_encounter = await session.execute(
select(Encounter).where(
Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids),
~Encounter.id.in_(transfer_target_ids),
)
lock_query = select(Encounter).where(
Encounter.run_id == run_id,
Encounter.route_id.in_(sibling_ids),
~Encounter.id.in_(transfer_target_ids),
)
# Gift-origin encounters don't count toward route lock
if gift_clause_on:
lock_query = lock_query.where(
Encounter.origin.is_(None) | (Encounter.origin != "gift")
)
existing_encounter = await session.execute(lock_query)
if existing_encounter.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
@@ -119,6 +124,7 @@ async def create_encounter(
status=data.status,
catch_level=data.catch_level,
is_shiny=data.is_shiny,
origin=data.origin,
)
session.add(encounter)
await session.commit()

View File

@@ -24,6 +24,7 @@ class Encounter(Base):
is_shiny: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false")
)
origin: Mapped[str | None] = mapped_column(String(20))
caught_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View File

@@ -35,6 +35,7 @@ class EncounterResponse(CamelModel):
faint_level: int | None
death_cause: str | None
is_shiny: bool
origin: str | None
caught_at: datetime

View File

@@ -144,6 +144,7 @@ RUN_DEFS = [
DEFAULT_RULES = {
"duplicatesClause": True,
"shinyClause": True,
"giftClause": False,
"pinwheelClause": True,
"levelCaps": False,
"hardcoreMode": False,