Add per-condition encounter rates to seed data (#26)
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com> Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
This commit was merged in pull request #26.
This commit is contained in:
@@ -43,6 +43,14 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
label: 'Super Rod',
|
||||
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
},
|
||||
horde: {
|
||||
label: 'Horde',
|
||||
color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
},
|
||||
sos: {
|
||||
label: 'SOS',
|
||||
color: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
},
|
||||
}
|
||||
|
||||
/** Display order for encounter method groups */
|
||||
@@ -58,6 +66,8 @@ export const METHOD_ORDER = [
|
||||
'old-rod',
|
||||
'good-rod',
|
||||
'super-rod',
|
||||
'horde',
|
||||
'sos',
|
||||
]
|
||||
|
||||
export function getMethodLabel(method: string): string {
|
||||
|
||||
@@ -62,14 +62,90 @@ const statusOptions: {
|
||||
|
||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||
|
||||
function groupByMethod(
|
||||
pokemon: RouteEncounterDetail[]
|
||||
): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||
interface GroupedEncounter {
|
||||
encounter: RouteEncounterDetail
|
||||
conditions: string[]
|
||||
displayRate: number | null
|
||||
}
|
||||
|
||||
function getUniqueConditions(pokemon: RouteEncounterDetail[]): string[] {
|
||||
const conditions = new Set<string>()
|
||||
for (const rp of pokemon) {
|
||||
const list = groups.get(rp.encounterMethod) ?? []
|
||||
list.push(rp)
|
||||
groups.set(rp.encounterMethod, list)
|
||||
if (rp.condition) conditions.add(rp.condition)
|
||||
}
|
||||
return [...conditions].sort()
|
||||
}
|
||||
|
||||
function groupByMethod(
|
||||
pokemon: RouteEncounterDetail[],
|
||||
selectedCondition: string | null
|
||||
): { method: string; pokemon: GroupedEncounter[] }[] {
|
||||
const groups = new Map<string, Map<number, GroupedEncounter>>()
|
||||
|
||||
// Build a lookup: pokemonId+method -> condition -> rate
|
||||
const rateByCondition = new Map<string, Map<string, number>>()
|
||||
for (const rp of pokemon) {
|
||||
if (rp.condition) {
|
||||
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||
let condMap = rateByCondition.get(key)
|
||||
if (!condMap) {
|
||||
condMap = new Map()
|
||||
rateByCondition.set(key, condMap)
|
||||
}
|
||||
condMap.set(rp.condition, rp.encounterRate)
|
||||
}
|
||||
}
|
||||
|
||||
for (const rp of pokemon) {
|
||||
// When a specific condition is selected, skip pokemon with 0% under that condition
|
||||
if (selectedCondition) {
|
||||
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||
const condMap = rateByCondition.get(key)
|
||||
if (condMap) {
|
||||
const rate = condMap.get(selectedCondition)
|
||||
if (rate === 0) continue
|
||||
// Skip entries for other conditions (we only want one entry per pokemon)
|
||||
if (rp.condition && rp.condition !== selectedCondition) continue
|
||||
}
|
||||
} else {
|
||||
// "All" mode: skip 0% entries
|
||||
if (rp.encounterRate === 0 && rp.condition) continue
|
||||
}
|
||||
|
||||
let methodGroup = groups.get(rp.encounterMethod)
|
||||
if (!methodGroup) {
|
||||
methodGroup = new Map()
|
||||
groups.set(rp.encounterMethod, methodGroup)
|
||||
}
|
||||
|
||||
const existing = methodGroup.get(rp.pokemonId)
|
||||
if (existing) {
|
||||
if (rp.condition) existing.conditions.push(rp.condition)
|
||||
} else {
|
||||
// Determine the display rate
|
||||
let displayRate: number | null = null
|
||||
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod)
|
||||
if (!isSpecial) {
|
||||
if (selectedCondition) {
|
||||
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||
const condMap = rateByCondition.get(key)
|
||||
if (condMap) {
|
||||
displayRate = condMap.get(selectedCondition) ?? null
|
||||
} else {
|
||||
displayRate = rp.encounterRate
|
||||
}
|
||||
} else if (!rp.condition) {
|
||||
// "All" mode: show the base rate for non-condition entries
|
||||
displayRate = rp.encounterRate
|
||||
}
|
||||
}
|
||||
|
||||
methodGroup.set(rp.pokemonId, {
|
||||
encounter: rp,
|
||||
conditions: rp.condition ? [rp.condition] : [],
|
||||
displayRate,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
@@ -77,14 +153,25 @@ function groupByMethod(
|
||||
const bi = METHOD_ORDER.indexOf(b)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
.map(([method, pokemon]) => ({ method, pokemon }))
|
||||
.map(([method, pokemonMap]) => ({
|
||||
method,
|
||||
pokemon: [...pokemonMap.values()].sort((a, b) => (b.displayRate ?? 0) - (a.displayRate ?? 0)),
|
||||
}))
|
||||
}
|
||||
|
||||
function pickRandomPokemon(
|
||||
pokemon: RouteEncounterDetail[],
|
||||
dupedIds?: Set<number>
|
||||
): RouteEncounterDetail | null {
|
||||
const eligible = dupedIds ? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId)) : pokemon
|
||||
// Deduplicate by pokemonId (conditions may create multiple entries)
|
||||
const seen = new Set<number>()
|
||||
const unique = pokemon.filter((rp) => {
|
||||
if (rp.encounterRate === 0) return false
|
||||
if (seen.has(rp.pokemonId)) return false
|
||||
seen.add(rp.pokemonId)
|
||||
return true
|
||||
})
|
||||
const eligible = dupedIds ? unique.filter((rp) => !dupedIds.has(rp.pokemonId)) : unique
|
||||
if (eligible.length === 0) return null
|
||||
return eligible[Math.floor(Math.random() * eligible.length)] ?? null
|
||||
}
|
||||
@@ -112,6 +199,7 @@ export function EncounterModal({
|
||||
const [faintLevel, setFaintLevel] = useState<string>('')
|
||||
const [deathCause, setDeathCause] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
|
||||
|
||||
const isEditing = !!existing
|
||||
|
||||
@@ -131,13 +219,18 @@ export function EncounterModal({
|
||||
}
|
||||
}, [existing, routePokemon])
|
||||
|
||||
const availableConditions = useMemo(
|
||||
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
|
||||
[routePokemon]
|
||||
)
|
||||
|
||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const groupedPokemon = useMemo(
|
||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||
[filteredPokemon]
|
||||
() => (filteredPokemon ? groupByMethod(filteredPokemon, selectedCondition) : []),
|
||||
[filteredPokemon, selectedCondition]
|
||||
)
|
||||
const hasMultipleGroups = groupedPokemon.length > 1
|
||||
|
||||
@@ -235,6 +328,35 @@ export function EncounterModal({
|
||||
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
{availableConditions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCondition(null)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors ${
|
||||
selectedCondition === null
|
||||
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{availableConditions.map((cond) => (
|
||||
<button
|
||||
key={cond}
|
||||
type="button"
|
||||
onClick={() => setSelectedCondition(cond)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors capitalize ${
|
||||
selectedCondition === cond
|
||||
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600'
|
||||
}`}
|
||||
>
|
||||
{cond}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 overflow-y-auto space-y-3">
|
||||
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||||
<div key={method}>
|
||||
@@ -247,18 +369,21 @@ export function EncounterModal({
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{pokemon.map((rp) => {
|
||||
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
|
||||
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||
const isSelected =
|
||||
selectedPokemon?.pokemonId === rp.pokemonId &&
|
||||
selectedPokemon?.encounterMethod === rp.encounterMethod
|
||||
return (
|
||||
<button
|
||||
key={rp.id}
|
||||
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
||||
type="button"
|
||||
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
||||
disabled={isDuped}
|
||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||
isDuped
|
||||
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||||
: selectedPokemon?.id === rp.id
|
||||
: isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
@@ -287,6 +412,18 @@ export function EncounterModal({
|
||||
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||
)}
|
||||
{!isDuped && displayRate !== null && displayRate !== undefined && (
|
||||
<span className="text-[10px] text-purple-500 dark:text-purple-400 font-medium">
|
||||
{displayRate}%
|
||||
</span>
|
||||
)}
|
||||
{!isDuped &&
|
||||
selectedCondition === null &&
|
||||
conditions.length > 0 && (
|
||||
<span className="text-[10px] text-purple-500 dark:text-purple-400">
|
||||
{conditions.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{!isDuped && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Lv. {rp.minLevel}
|
||||
|
||||
@@ -63,6 +63,8 @@ export function AdminRouteDetail() {
|
||||
? Math.max(...childRoutes.map((r) => r.order)) + 1
|
||||
: (route?.order ?? 0) * 10 + 1
|
||||
|
||||
const hasConditions = encounters.some((e) => e.condition !== '')
|
||||
|
||||
const columns: Column<RouteEncounterDetail>[] = [
|
||||
{
|
||||
header: 'Pokemon',
|
||||
@@ -79,6 +81,14 @@ export function AdminRouteDetail() {
|
||||
},
|
||||
{ header: 'Method', accessor: (e) => e.encounterMethod },
|
||||
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
|
||||
...(hasConditions
|
||||
? [
|
||||
{
|
||||
header: 'Condition',
|
||||
accessor: (e: RouteEncounterDetail) => e.condition || '\u2014',
|
||||
} as Column<RouteEncounterDetail>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
header: 'Levels',
|
||||
accessor: (e) =>
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CreateRouteEncounterInput {
|
||||
gameId: number
|
||||
encounterMethod: string
|
||||
encounterRate: number
|
||||
condition?: string
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
}
|
||||
@@ -75,6 +76,7 @@ export interface CreateRouteEncounterInput {
|
||||
export interface UpdateRouteEncounterInput {
|
||||
encounterMethod?: string
|
||||
encounterRate?: number
|
||||
condition?: string
|
||||
minLevel?: number
|
||||
maxLevel?: number
|
||||
}
|
||||
@@ -128,6 +130,7 @@ export interface PokemonEncounterLocationItem {
|
||||
routeName: string
|
||||
encounterMethod: string
|
||||
encounterRate: number
|
||||
condition: string
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface RouteEncounter {
|
||||
gameId: number
|
||||
encounterMethod: string
|
||||
encounterRate: number
|
||||
condition: string
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user