Add Pinwheel Clause support for zone-based encounters in route groups
Allows each sub-zone within a route group to have its own independent encounter when the Pinwheel Clause rule is enabled (default on), instead of the entire group sharing a single encounter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Pinwheel Zone</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pinwheelZone}
|
||||
onChange={(e) => setPinwheelZone(e.target.value)}
|
||||
placeholder="None"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Routes in the same zone share an encounter when the Pinwheel Clause is active
|
||||
</p>
|
||||
</div>
|
||||
</FormModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,40 @@ function getGroupEncounter(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether any child in this group has a pinwheelZone set. */
|
||||
function groupHasZones(group: RouteWithChildren): boolean {
|
||||
return group.children.some((c) => c.pinwheelZone != null)
|
||||
}
|
||||
|
||||
/** Get the effective zone for a route (null treated as 0). */
|
||||
function effectiveZone(route: Route): number {
|
||||
return route.pinwheelZone ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounters grouped by zone within a route group.
|
||||
* Returns a Map from zone number to the encounter in that zone.
|
||||
*/
|
||||
function getZoneEncounters(
|
||||
group: RouteWithChildren,
|
||||
encounterByRoute: Map<number, EncounterDetail>,
|
||||
): Map<number, EncounterDetail> {
|
||||
const zoneMap = new Map<number, EncounterDetail>()
|
||||
for (const child of group.children) {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
if (enc) {
|
||||
zoneMap.set(effectiveZone(child), enc)
|
||||
}
|
||||
}
|
||||
return zoneMap
|
||||
}
|
||||
|
||||
/** Count distinct zones in a group. */
|
||||
function countDistinctZones(group: RouteWithChildren): number {
|
||||
const zones = new Set(group.children.map(effectiveZone))
|
||||
return zones.size
|
||||
}
|
||||
|
||||
interface RouteGroupProps {
|
||||
group: RouteWithChildren
|
||||
encounterByRoute: Map<number, EncounterDetail>
|
||||
@@ -110,6 +144,7 @@ interface RouteGroupProps {
|
||||
onToggleExpand: () => void
|
||||
onRouteClick: (route: Route) => void
|
||||
filter: 'all' | RouteStatus
|
||||
pinwheelClause: boolean
|
||||
}
|
||||
|
||||
function RouteGroup({
|
||||
@@ -119,14 +154,37 @@ function RouteGroup({
|
||||
onToggleExpand,
|
||||
onRouteClick,
|
||||
filter,
|
||||
pinwheelClause,
|
||||
}: RouteGroupProps) {
|
||||
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
||||
const groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
const usePinwheel = pinwheelClause && groupHasZones(group)
|
||||
const zoneEncounters = usePinwheel
|
||||
? getZoneEncounters(group, encounterByRoute)
|
||||
: null
|
||||
|
||||
// For pinwheel groups, determine status from all zone statuses
|
||||
let groupStatus: RouteStatus
|
||||
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
|
||||
// Use the first encounter's status as representative for the header
|
||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
} else {
|
||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
}
|
||||
const si = statusIndicator[groupStatus]
|
||||
|
||||
// For groups, check if it matches the filter
|
||||
if (filter !== 'all' && groupStatus !== filter) {
|
||||
return null
|
||||
if (filter !== 'all') {
|
||||
if (usePinwheel) {
|
||||
// Show group if any zone matches the filter
|
||||
const anyChildMatches = group.children.some((child) => {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
// Also check children without encounters (for 'none' filter)
|
||||
if (!anyChildMatches) return null
|
||||
} else if (groupStatus !== filter) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const hasGroupEncounter = groupEncounter !== null
|
||||
@@ -192,7 +250,16 @@ function RouteGroup({
|
||||
const childEncounter = encounterByRoute.get(child.id)
|
||||
const childStatus = getRouteStatus(childEncounter)
|
||||
const childSi = statusIndicator[childStatus]
|
||||
const isDisabled = hasGroupEncounter && !childEncounter
|
||||
|
||||
let isDisabled: boolean
|
||||
if (usePinwheel && zoneEncounters) {
|
||||
// Zone-aware: only lock if this child's zone already has an encounter
|
||||
const myZone = effectiveZone(child)
|
||||
isDisabled = zoneEncounters.has(myZone) && !childEncounter
|
||||
} else {
|
||||
// Classic: whole group shares one encounter
|
||||
isDisabled = hasGroupEncounter && !childEncounter
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -334,17 +401,32 @@ export function RunEncounters() {
|
||||
)
|
||||
}
|
||||
|
||||
// Count completed locations (groups count as 1, standalone routes count as 1)
|
||||
const completedCount = organizedRoutes.filter((r) => {
|
||||
if (r.children.length > 0) {
|
||||
// It's a group - check if any child has an encounter
|
||||
return getGroupEncounter(r, encounterByRoute) !== null
|
||||
}
|
||||
// Standalone route
|
||||
return encounterByRoute.has(r.id)
|
||||
}).length
|
||||
const pinwheelClause = run.rules?.pinwheelClause ?? true
|
||||
|
||||
const totalLocations = organizedRoutes.length
|
||||
// Count completed locations (zone-aware when pinwheel clause is on)
|
||||
let completedCount = 0
|
||||
let totalLocations = 0
|
||||
for (const r of organizedRoutes) {
|
||||
if (r.children.length > 0) {
|
||||
const usePinwheel = pinwheelClause && groupHasZones(r)
|
||||
if (usePinwheel) {
|
||||
const distinctZones = countDistinctZones(r)
|
||||
const zoneEncs = getZoneEncounters(r, encounterByRoute)
|
||||
totalLocations += distinctZones
|
||||
completedCount += zoneEncs.size
|
||||
} else {
|
||||
totalLocations += 1
|
||||
if (getGroupEncounter(r, encounterByRoute) !== null) {
|
||||
completedCount += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
totalLocations += 1
|
||||
if (encounterByRoute.has(r.id)) {
|
||||
completedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = run.status === 'active'
|
||||
const alive = run.encounters.filter(
|
||||
@@ -413,7 +495,15 @@ export function RunEncounters() {
|
||||
if (filter === 'all') return true
|
||||
|
||||
if (r.children.length > 0) {
|
||||
// It's a group
|
||||
const usePinwheel = pinwheelClause && groupHasZones(r)
|
||||
if (usePinwheel) {
|
||||
// Show group if any child/zone matches the filter
|
||||
return r.children.some((child) => {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
}
|
||||
// Classic: single status for whole group
|
||||
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
||||
return getRouteStatus(groupEnc ?? undefined) === filter
|
||||
}
|
||||
@@ -665,6 +755,7 @@ export function RunEncounters() {
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
filter={filter}
|
||||
pinwheelClause={pinwheelClause}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ export interface UpdateGameInput {
|
||||
export interface CreateRouteInput {
|
||||
name: string
|
||||
order: number
|
||||
pinwheelZone?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateRouteInput {
|
||||
name?: string
|
||||
order?: number
|
||||
pinwheelZone?: number | null
|
||||
}
|
||||
|
||||
export interface RouteReorderItem {
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Route {
|
||||
gameId: number
|
||||
order: number
|
||||
parentRouteId: number | null
|
||||
pinwheelZone: number | null
|
||||
encounterMethods: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface NuzlockeRules {
|
||||
nicknameRequired: boolean
|
||||
duplicatesClause: boolean
|
||||
shinyClause: boolean
|
||||
pinwheelClause: boolean
|
||||
|
||||
// Difficulty modifiers
|
||||
hardcoreMode: boolean
|
||||
@@ -19,6 +20,7 @@ export const DEFAULT_RULES: NuzlockeRules = {
|
||||
nicknameRequired: true,
|
||||
duplicatesClause: true,
|
||||
shinyClause: true,
|
||||
pinwheelClause: true,
|
||||
|
||||
// Difficulty modifiers - off by default
|
||||
hardcoreMode: false,
|
||||
@@ -70,6 +72,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
||||
'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'pinwheelClause',
|
||||
name: 'Pinwheel Clause',
|
||||
description:
|
||||
'Sub-zones within a location group each get their own encounter instead of sharing one.',
|
||||
category: 'core',
|
||||
},
|
||||
|
||||
// Difficulty modifiers
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user