Compare commits
3 Commits
ed1f7ad3d0
...
347c25e8ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 347c25e8ed | |||
| 6968d35a33 | |||
| 18cc116348 |
@@ -1,33 +1,29 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-fv7w
|
# nuzlocke-tracker-fv7w
|
||||||
title: Add team size limit rule
|
title: Add boss team match rule
|
||||||
status: todo
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-20T19:56:22Z
|
created_at: 2026-02-20T19:56:22Z
|
||||||
updated_at: 2026-02-20T20:01:53Z
|
updated_at: 2026-02-20T21:01:36Z
|
||||||
parent: nuzlocke-tracker-49xj
|
parent: nuzlocke-tracker-49xj
|
||||||
---
|
---
|
||||||
|
|
||||||
Cap the active party size with warnings when the limit is exceeded.
|
When enabled, hint to the player that they should limit their active party to the same number of Pokemon as the next boss fight. This is a self-imposed difficulty rule — the tracker cannot enforce it since it doesn't track the active party, but it can surface the information.
|
||||||
|
|
||||||
## Design Decisions
|
## Design
|
||||||
|
|
||||||
**Configurable limit:** Add `teamSizeLimit: number | null` to `NuzlockeRules`. `null` means no limit (disabled). Default Pokemon party size is 6, but variants like "trio-locke" use 3.
|
**Rule:** Add `bossTeamMatch: boolean` to `NuzlockeRules` (default: `false`, category: `playstyle`).
|
||||||
|
|
||||||
**What counts:** "Active team" = encounters with status `caught` and not fainted. The tracker already tracks this — alive Pokemon are shown in the team section on RunEncounters.
|
**Display:** When enabled and the sticky boss banner is shown, add a hint next to the boss name showing their team size, e.g. "Next: Brock (2 Pokemon — match their team)". This reuses the existing `nextBoss` and its `pokemon` array.
|
||||||
|
|
||||||
**No PC box tracking:** The tracker doesn't model a PC box. Excess catches beyond the team limit are still logged normally. The tracker just warns that the team is over capacity.
|
**Variant bosses:** Some bosses have conditional teams (e.g. rival starter choice). Use the same logic as `BossTeamPreview`: count pokemon without a `conditionLabel` plus those matching the auto-detected variant (via `matchVariant`). Falls back to first variant if no match is detected.
|
||||||
|
|
||||||
**Enforcement:** Soft enforcement. Show a warning banner on the encounters page when alive count exceeds the limit. Highlight the count in the team section header. Don't block new catches.
|
**Scope:** Frontend-only. No backend or data model changes needed.
|
||||||
|
|
||||||
**UI:** Add a numeric input to `RulesConfiguration` (shown when team size toggle is on, min 1, max 6). Display the limit in the sticky bar alongside level caps if enabled.
|
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Add `teamSizeLimit: number | null` to `NuzlockeRules` interface (default: `null`)
|
- [x] Add `bossTeamMatch: boolean` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
|
||||||
- [ ] Add `RuleDefinition` entry under `'difficulty'` category
|
- [x] Add `RuleDefinition` entry (category: `playstyle`)
|
||||||
- [ ] Add numeric input to `RulesConfiguration` (shown when enabled, min 1, max 6)
|
- [x] Show boss team size hint in the sticky level cap banner when the rule is enabled
|
||||||
- [ ] Show warning banner on RunEncounters when alive team count exceeds limit
|
- [x] Handle variant boss teams (use auto-matched variant count when available)
|
||||||
- [ ] Display team size limit in sticky bar alongside level caps
|
|
||||||
- [ ] Show count in team section header (e.g., "Team (4/3)" in red when over)
|
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-sij8
|
# nuzlocke-tracker-sij8
|
||||||
title: Add gift clause rule
|
title: Add gift clause rule
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-20T19:56:10Z
|
created_at: 2026-02-20T19:56:10Z
|
||||||
updated_at: 2026-02-20T19:56:10Z
|
updated_at: 2026-02-20T20:55:23Z
|
||||||
parent: nuzlocke-tracker-49xj
|
parent: nuzlocke-tracker-49xj
|
||||||
---
|
---
|
||||||
|
|
||||||
Add a new `giftClause` boolean rule: in-game gift Pokemon are free and do not count against the area's encounter limit.
|
Add a new giftClause boolean rule: in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, a location with a gift allows both the gift encounter and a regular encounter, in any order.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Add `giftClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
|
- [x] Add giftClause to NuzlockeRules interface and DEFAULT_RULES (default: false)
|
||||||
- [ ] Add `RuleDefinition` entry with appropriate category
|
- [x] Add RuleDefinition entry with core category
|
||||||
- [ ] When enabled, gift-origin encounters bypass the route-lock check in the backend (similar to shinyClause bypass)
|
- [x] Add origin column to Encounter model + alembic migration
|
||||||
- [ ] Update the encounter creation endpoint to check for giftClause when origin is "gift"
|
- [x] Add origin to EncounterResponse schema and frontend Encounter type
|
||||||
|
- [x] Persist origin when creating encounters (frontend sends, backend stores)
|
||||||
|
- [x] Backend: gift-origin encounters bypass route-lock check (skip_route_lock)
|
||||||
|
- [x] Backend: existing gift encounters excluded from route-lock query
|
||||||
|
- [x] Frontend: split encounterByRoute into regular and gift maps
|
||||||
|
- [x] Frontend: routes with only gift encounters remain clickable for new encounters
|
||||||
|
- [x] Frontend: gift encounters displayed on route cards with (gift) label
|
||||||
|
- [x] Frontend: route filtering accounts for gift encounters
|
||||||
@@ -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")
|
||||||
@@ -58,12 +58,13 @@ async def create_encounter(
|
|||||||
detail="Cannot create encounter on a parent route. Use a child route instead.",
|
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
|
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 (
|
gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
|
||||||
"shed_evolution",
|
skip_route_lock = (
|
||||||
"egg",
|
(data.is_shiny and shiny_clause_on)
|
||||||
"transfer",
|
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
|
# 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
|
# Check if any relevant sibling already has an encounter in this run
|
||||||
# Exclude transfer-target encounters so they don't block the starter
|
# Exclude transfer-target encounters so they don't block the starter
|
||||||
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
|
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
|
||||||
existing_encounter = await session.execute(
|
lock_query = select(Encounter).where(
|
||||||
select(Encounter).where(
|
Encounter.run_id == run_id,
|
||||||
Encounter.run_id == run_id,
|
Encounter.route_id.in_(sibling_ids),
|
||||||
Encounter.route_id.in_(sibling_ids),
|
~Encounter.id.in_(transfer_target_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:
|
if existing_encounter.scalar_one_or_none() is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
@@ -119,6 +124,7 @@ async def create_encounter(
|
|||||||
status=data.status,
|
status=data.status,
|
||||||
catch_level=data.catch_level,
|
catch_level=data.catch_level,
|
||||||
is_shiny=data.is_shiny,
|
is_shiny=data.is_shiny,
|
||||||
|
origin=data.origin,
|
||||||
)
|
)
|
||||||
session.add(encounter)
|
session.add(encounter)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Encounter(Base):
|
|||||||
is_shiny: Mapped[bool] = mapped_column(
|
is_shiny: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default=text("false")
|
Boolean, default=False, server_default=text("false")
|
||||||
)
|
)
|
||||||
|
origin: Mapped[str | None] = mapped_column(String(20))
|
||||||
caught_at: Mapped[datetime] = mapped_column(
|
caught_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class EncounterResponse(CamelModel):
|
|||||||
faint_level: int | None
|
faint_level: int | None
|
||||||
death_cause: str | None
|
death_cause: str | None
|
||||||
is_shiny: bool
|
is_shiny: bool
|
||||||
|
origin: str | None
|
||||||
caught_at: datetime
|
caught_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -144,10 +144,12 @@ RUN_DEFS = [
|
|||||||
DEFAULT_RULES = {
|
DEFAULT_RULES = {
|
||||||
"duplicatesClause": True,
|
"duplicatesClause": True,
|
||||||
"shinyClause": True,
|
"shinyClause": True,
|
||||||
|
"giftClause": False,
|
||||||
"pinwheelClause": True,
|
"pinwheelClause": True,
|
||||||
"levelCaps": False,
|
"levelCaps": False,
|
||||||
"hardcoreMode": False,
|
"hardcoreMode": False,
|
||||||
"setModeOnly": False,
|
"setModeOnly": False,
|
||||||
|
"bossTeamMatch": False,
|
||||||
"egglocke": False,
|
"egglocke": False,
|
||||||
"wonderlocke": False,
|
"wonderlocke": False,
|
||||||
"randomizer": False,
|
"randomizer": False,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface EncounterModalProps {
|
|||||||
nickname?: string | undefined
|
nickname?: string | undefined
|
||||||
status: EncounterStatus
|
status: EncounterStatus
|
||||||
catchLevel?: number | undefined
|
catchLevel?: number | undefined
|
||||||
|
origin?: string | undefined
|
||||||
}) => void
|
}) => void
|
||||||
onUpdate?:
|
onUpdate?:
|
||||||
| ((data: {
|
| ((data: {
|
||||||
@@ -291,6 +292,7 @@ export function EncounterModal({
|
|||||||
nickname: nickname || undefined,
|
nickname: nickname || undefined,
|
||||||
status,
|
status,
|
||||||
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
||||||
|
origin: SPECIAL_METHODS.includes(selectedPokemon.encounterMethod) ? 'gift' : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,17 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
|
|||||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Count boss pokemon for the effective variant (or all if no variants). */
|
||||||
|
function getBossTeamSize(pokemon: BossPokemon[], starterName?: string | null): number {
|
||||||
|
const labels = [
|
||||||
|
...new Set(pokemon.filter((bp) => bp.conditionLabel).map((bp) => bp.conditionLabel!)),
|
||||||
|
]
|
||||||
|
if (labels.length === 0) return pokemon.length
|
||||||
|
const matched = matchVariant(labels, starterName)
|
||||||
|
const variant = matched ?? labels[0] ?? null
|
||||||
|
return pokemon.filter((bp) => bp.conditionLabel === variant || bp.conditionLabel === null).length
|
||||||
|
}
|
||||||
|
|
||||||
function BossTeamPreview({
|
function BossTeamPreview({
|
||||||
pokemon,
|
pokemon,
|
||||||
starterName,
|
starterName,
|
||||||
@@ -254,6 +265,7 @@ function BossTeamPreview({
|
|||||||
interface RouteGroupProps {
|
interface RouteGroupProps {
|
||||||
group: RouteWithChildren
|
group: RouteWithChildren
|
||||||
encounterByRoute: Map<number, EncounterDetail>
|
encounterByRoute: Map<number, EncounterDetail>
|
||||||
|
giftEncounterByRoute: Map<number, EncounterDetail>
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggleExpand: () => void
|
onToggleExpand: () => void
|
||||||
onRouteClick: (route: Route) => void
|
onRouteClick: (route: Route) => void
|
||||||
@@ -264,6 +276,7 @@ interface RouteGroupProps {
|
|||||||
function RouteGroup({
|
function RouteGroup({
|
||||||
group,
|
group,
|
||||||
encounterByRoute,
|
encounterByRoute,
|
||||||
|
giftEncounterByRoute,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onRouteClick,
|
onRouteClick,
|
||||||
@@ -274,13 +287,23 @@ function RouteGroup({
|
|||||||
const usePinwheel = pinwheelClause && groupHasZones(group)
|
const usePinwheel = pinwheelClause && groupHasZones(group)
|
||||||
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
|
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
|
||||||
|
|
||||||
|
// Find first gift encounter in the group (for display)
|
||||||
|
let groupGiftEncounter: EncounterDetail | null = null
|
||||||
|
for (const child of group.children) {
|
||||||
|
const gift = giftEncounterByRoute.get(child.id)
|
||||||
|
if (gift) {
|
||||||
|
groupGiftEncounter = gift
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const displayEncounter = groupEncounter ?? groupGiftEncounter
|
||||||
|
|
||||||
// For pinwheel groups, determine status from all zone statuses
|
// For pinwheel groups, determine status from all zone statuses
|
||||||
let groupStatus: RouteStatus
|
let groupStatus: RouteStatus
|
||||||
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
|
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
|
||||||
// Use the first encounter's status as representative for the header
|
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
||||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
|
||||||
} else {
|
} else {
|
||||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
||||||
}
|
}
|
||||||
const si = statusIndicator[groupStatus]
|
const si = statusIndicator[groupStatus]
|
||||||
|
|
||||||
@@ -289,10 +312,9 @@ function RouteGroup({
|
|||||||
if (usePinwheel) {
|
if (usePinwheel) {
|
||||||
// Show group if any zone matches the filter
|
// Show group if any zone matches the filter
|
||||||
const anyChildMatches = group.children.some((child) => {
|
const anyChildMatches = group.children.some((child) => {
|
||||||
const enc = encounterByRoute.get(child.id)
|
const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.get(child.id)
|
||||||
return getRouteStatus(enc) === filter
|
return getRouteStatus(enc) === filter
|
||||||
})
|
})
|
||||||
// Also check children without encounters (for 'none' filter)
|
|
||||||
if (!anyChildMatches) return null
|
if (!anyChildMatches) return null
|
||||||
} else if (groupStatus !== filter) {
|
} else if (groupStatus !== filter) {
|
||||||
return null
|
return null
|
||||||
@@ -330,6 +352,36 @@ function RouteGroup({
|
|||||||
groupEncounter.faintLevel !== null &&
|
groupEncounter.faintLevel !== null &&
|
||||||
(groupEncounter.deathCause ? ` — ${groupEncounter.deathCause}` : ' (dead)')}
|
(groupEncounter.deathCause ? ` — ${groupEncounter.deathCause}` : ' (dead)')}
|
||||||
</span>
|
</span>
|
||||||
|
{groupGiftEncounter && (
|
||||||
|
<>
|
||||||
|
{groupGiftEncounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={groupGiftEncounter.pokemon.spriteUrl}
|
||||||
|
alt={groupGiftEncounter.pokemon.name}
|
||||||
|
className="w-8 h-8 opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-tertiary capitalize">
|
||||||
|
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
|
||||||
|
<span className="text-text-muted ml-1">(gift)</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!groupEncounter && groupGiftEncounter && (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{groupGiftEncounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={groupGiftEncounter.pokemon.spriteUrl}
|
||||||
|
alt={groupGiftEncounter.pokemon.name}
|
||||||
|
className="w-8 h-8 opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-tertiary capitalize">
|
||||||
|
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
|
||||||
|
<span className="text-text-muted ml-1">(gift)</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -349,7 +401,9 @@ function RouteGroup({
|
|||||||
<div className="border-t border-border-default bg-surface-1/50">
|
<div className="border-t border-border-default bg-surface-1/50">
|
||||||
{group.children.map((child) => {
|
{group.children.map((child) => {
|
||||||
const childEncounter = encounterByRoute.get(child.id)
|
const childEncounter = encounterByRoute.get(child.id)
|
||||||
const childStatus = getRouteStatus(childEncounter)
|
const giftEncounter = giftEncounterByRoute.get(child.id)
|
||||||
|
const displayEncounter = childEncounter ?? giftEncounter
|
||||||
|
const childStatus = getRouteStatus(displayEncounter)
|
||||||
const childSi = statusIndicator[childStatus]
|
const childSi = statusIndicator[childStatus]
|
||||||
|
|
||||||
let isDisabled: boolean
|
let isDisabled: boolean
|
||||||
@@ -375,7 +429,22 @@ function RouteGroup({
|
|||||||
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
|
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-text-secondary">{child.name}</div>
|
<div className="text-sm text-text-secondary">{child.name}</div>
|
||||||
{!childEncounter && child.encounterMethods.length > 0 && (
|
{giftEncounter && !childEncounter && (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{giftEncounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={giftEncounter.pokemon.spriteUrl}
|
||||||
|
alt={giftEncounter.pokemon.name}
|
||||||
|
className="w-8 h-8 opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-tertiary capitalize">
|
||||||
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||||
|
<span className="text-text-muted ml-1">(gift)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!displayEncounter && child.encounterMethods.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
{child.encounterMethods.map((m) => (
|
{child.encounterMethods.map((m) => (
|
||||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||||
@@ -484,14 +553,29 @@ export function RunEncounters() {
|
|||||||
}
|
}
|
||||||
}, [run, transferIdSet])
|
}, [run, transferIdSet])
|
||||||
|
|
||||||
// Map routeId → encounter for quick lookup (normal encounters only)
|
const giftClauseOn = run?.rules?.giftClause ?? false
|
||||||
|
|
||||||
|
// Map routeId → encounter for quick lookup (normal encounters only).
|
||||||
|
// When gift clause is on, gift-origin encounters are excluded so they
|
||||||
|
// don't lock the route for a regular encounter.
|
||||||
const encounterByRoute = useMemo(() => {
|
const encounterByRoute = useMemo(() => {
|
||||||
const map = new Map<number, EncounterDetail>()
|
const map = new Map<number, EncounterDetail>()
|
||||||
for (const enc of normalEncounters) {
|
for (const enc of normalEncounters) {
|
||||||
|
if (giftClauseOn && enc.origin === 'gift') continue
|
||||||
map.set(enc.routeId, enc)
|
map.set(enc.routeId, enc)
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [normalEncounters])
|
}, [normalEncounters, giftClauseOn])
|
||||||
|
|
||||||
|
// Separate map for gift encounters (only populated when gift clause is on)
|
||||||
|
const giftEncounterByRoute = useMemo(() => {
|
||||||
|
const map = new Map<number, EncounterDetail>()
|
||||||
|
if (!giftClauseOn) return map
|
||||||
|
for (const enc of normalEncounters) {
|
||||||
|
if (enc.origin === 'gift') map.set(enc.routeId, enc)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [normalEncounters, giftClauseOn])
|
||||||
|
|
||||||
// Build set of retired Pokemon IDs from genlocke context
|
// Build set of retired Pokemon IDs from genlocke context
|
||||||
const retiredPokemonIds = useMemo(() => {
|
const retiredPokemonIds = useMemo(() => {
|
||||||
@@ -756,7 +840,7 @@ export function RunEncounters() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter routes
|
// Filter routes (check both regular and gift encounters for status)
|
||||||
const filteredRoutes = organizedRoutes.filter((r) => {
|
const filteredRoutes = organizedRoutes.filter((r) => {
|
||||||
if (filter === 'all') return true
|
if (filter === 'all') return true
|
||||||
|
|
||||||
@@ -765,17 +849,23 @@ export function RunEncounters() {
|
|||||||
if (usePinwheel) {
|
if (usePinwheel) {
|
||||||
// Show group if any child/zone matches the filter
|
// Show group if any child/zone matches the filter
|
||||||
return r.children.some((child) => {
|
return r.children.some((child) => {
|
||||||
const enc = encounterByRoute.get(child.id)
|
const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.get(child.id)
|
||||||
return getRouteStatus(enc) === filter
|
return getRouteStatus(enc) === filter
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Classic: single status for whole group
|
// Classic: single status for whole group
|
||||||
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
||||||
return getRouteStatus(groupEnc ?? undefined) === filter
|
if (groupEnc) return getRouteStatus(groupEnc) === filter
|
||||||
|
// Check gift encounters if no regular encounter in group
|
||||||
|
for (const child of r.children) {
|
||||||
|
const gift = giftEncounterByRoute.get(child.id)
|
||||||
|
if (gift) return getRouteStatus(gift) === filter
|
||||||
|
}
|
||||||
|
return filter === 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone route
|
// Standalone route
|
||||||
const enc = encounterByRoute.get(r.id)
|
const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.get(r.id)
|
||||||
return getRouteStatus(enc) === filter
|
return getRouteStatus(enc) === filter
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -974,14 +1064,22 @@ export function RunEncounters() {
|
|||||||
|
|
||||||
{/* Level Cap Bar */}
|
{/* Level Cap Bar */}
|
||||||
{run.rules?.levelCaps && sortedBosses.length > 0 && (
|
{run.rules?.levelCaps && sortedBosses.length > 0 && (
|
||||||
<div className="sticky top-0 z-10 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
|
<div className="sticky top-14 z-30 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-semibold text-text-primary">
|
<span className="text-sm font-semibold text-text-primary">
|
||||||
Level Cap: {currentLevelCap ?? '—'}
|
Level Cap: {currentLevelCap ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
{nextBoss && (
|
{nextBoss && (
|
||||||
<span className="text-sm text-text-tertiary">Next: {nextBoss.name}</span>
|
<span className="text-sm text-text-tertiary">
|
||||||
|
Next: {nextBoss.name}
|
||||||
|
{run.rules?.bossTeamMatch && (
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{' '}
|
||||||
|
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon — match their team)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{!nextBoss && (
|
{!nextBoss && (
|
||||||
<span className="text-sm text-status-active">All bosses defeated!</span>
|
<span className="text-sm text-status-active">All bosses defeated!</span>
|
||||||
@@ -1226,6 +1324,7 @@ export function RunEncounters() {
|
|||||||
key={route.id}
|
key={route.id}
|
||||||
group={route}
|
group={route}
|
||||||
encounterByRoute={encounterByRoute}
|
encounterByRoute={encounterByRoute}
|
||||||
|
giftEncounterByRoute={giftEncounterByRoute}
|
||||||
isExpanded={expandedGroups.has(route.id)}
|
isExpanded={expandedGroups.has(route.id)}
|
||||||
onToggleExpand={() => toggleGroup(route.id)}
|
onToggleExpand={() => toggleGroup(route.id)}
|
||||||
onRouteClick={handleRouteClick}
|
onRouteClick={handleRouteClick}
|
||||||
@@ -1235,7 +1334,9 @@ export function RunEncounters() {
|
|||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
const encounter = encounterByRoute.get(route.id)
|
const encounter = encounterByRoute.get(route.id)
|
||||||
const rs = getRouteStatus(encounter)
|
const giftEncounter = giftEncounterByRoute.get(route.id)
|
||||||
|
const displayEncounter = encounter ?? giftEncounter
|
||||||
|
const rs = getRouteStatus(displayEncounter)
|
||||||
const si = statusIndicator[rs]
|
const si = statusIndicator[rs]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1263,6 +1364,35 @@ export function RunEncounters() {
|
|||||||
encounter.faintLevel !== null &&
|
encounter.faintLevel !== null &&
|
||||||
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
||||||
</span>
|
</span>
|
||||||
|
{giftEncounter && (
|
||||||
|
<>
|
||||||
|
{giftEncounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={giftEncounter.pokemon.spriteUrl}
|
||||||
|
alt={giftEncounter.pokemon.name}
|
||||||
|
className="w-8 h-8 opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-tertiary capitalize">
|
||||||
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||||
|
<span className="text-text-muted ml-1">(gift)</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : giftEncounter ? (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{giftEncounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={giftEncounter.pokemon.spriteUrl}
|
||||||
|
alt={giftEncounter.pokemon.name}
|
||||||
|
className="w-8 h-8 opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-tertiary capitalize">
|
||||||
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||||
|
<span className="text-text-muted ml-1">(gift)</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
route.encounterMethods.length > 0 && (
|
route.encounterMethods.length > 0 && (
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface Encounter {
|
|||||||
faintLevel: number | null
|
faintLevel: number | null
|
||||||
deathCause: string | null
|
deathCause: string | null
|
||||||
isShiny: boolean
|
isShiny: boolean
|
||||||
|
origin: string | null
|
||||||
caughtAt: string
|
caughtAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ export interface NuzlockeRules {
|
|||||||
// Core rules (affect tracker behavior)
|
// Core rules (affect tracker behavior)
|
||||||
duplicatesClause: boolean
|
duplicatesClause: boolean
|
||||||
shinyClause: boolean
|
shinyClause: boolean
|
||||||
|
giftClause: boolean
|
||||||
pinwheelClause: boolean
|
pinwheelClause: boolean
|
||||||
levelCaps: boolean
|
levelCaps: boolean
|
||||||
|
|
||||||
// Playstyle (informational, for stats/categorization)
|
// Playstyle (informational, for stats/categorization)
|
||||||
hardcoreMode: boolean
|
hardcoreMode: boolean
|
||||||
setModeOnly: boolean
|
setModeOnly: boolean
|
||||||
|
bossTeamMatch: boolean
|
||||||
|
|
||||||
// Variant (changes which Pokemon can appear)
|
// Variant (changes which Pokemon can appear)
|
||||||
egglocke: boolean
|
egglocke: boolean
|
||||||
@@ -19,12 +21,14 @@ export const DEFAULT_RULES: NuzlockeRules = {
|
|||||||
// Core rules
|
// Core rules
|
||||||
duplicatesClause: true,
|
duplicatesClause: true,
|
||||||
shinyClause: true,
|
shinyClause: true,
|
||||||
|
giftClause: false,
|
||||||
pinwheelClause: true,
|
pinwheelClause: true,
|
||||||
levelCaps: false,
|
levelCaps: false,
|
||||||
|
|
||||||
// Playstyle - off by default
|
// Playstyle - off by default
|
||||||
hardcoreMode: false,
|
hardcoreMode: false,
|
||||||
setModeOnly: false,
|
setModeOnly: false,
|
||||||
|
bossTeamMatch: false,
|
||||||
|
|
||||||
// Variant - off by default
|
// Variant - off by default
|
||||||
egglocke: false,
|
egglocke: false,
|
||||||
@@ -55,6 +59,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
|||||||
'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
|
'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
|
||||||
category: 'core',
|
category: 'core',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'giftClause',
|
||||||
|
name: 'Gift Clause',
|
||||||
|
description:
|
||||||
|
"In-game gift Pokémon (starters, trades, fossils) do not count against a location's encounter limit.",
|
||||||
|
category: 'core',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'pinwheelClause',
|
key: 'pinwheelClause',
|
||||||
name: 'Pinwheel Clause',
|
name: 'Pinwheel Clause',
|
||||||
@@ -84,6 +95,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
|||||||
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
|
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
|
||||||
category: 'playstyle',
|
category: 'playstyle',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'bossTeamMatch',
|
||||||
|
name: 'Boss Team Match',
|
||||||
|
description:
|
||||||
|
'Limit your active party to the same number of Pokémon as the boss you are challenging.',
|
||||||
|
category: 'playstyle',
|
||||||
|
},
|
||||||
|
|
||||||
// Variant
|
// Variant
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user