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:
2026-02-07 20:22:36 +01:00
parent 0b874a6816
commit 4fb6d43305
16 changed files with 233 additions and 22 deletions

View File

@@ -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}
/>
)
}