zenno/race/skillzone: new tool for viewing skill activation locations

Fixes #48.
This commit is contained in:
2026-10-31 13:21:37 -04:00
parent 2d935b87ec
commit 5a2b9b7bf4
6 changed files with 954 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import * as Plot from '@observablehq/plot';
import type { Attachment } from 'svelte/attachments';
import type { ClassValue } from 'svelte/elements';
import * as race from '$lib/race';
import * as racecourse from '$lib/data/racecourse';
export interface DrawSpan {
fy: string;
x1: number;
x2: number;
color: string;
label?: string;
}
interface Props {
course: racecourse.Course;
title?: string;
class?: ClassValue | null;
plotOptions?: Omit<Plot.PlotOptions, 'marks' | 'x' | 'y'>;
fontColor?: string;
draw: DrawSpan[];
}
let { course, title, class: className, plotOptions, fontColor, draw }: Props = $props();
let width = $state(0);
let height = $state(0);
const phases = $derived(race.phases(course.distance));
const phaseX = $derived([
0,
phases[race.Phase.EarlyRace],
phases[race.Phase.MidRace],
phases[race.Phase.LateRace],
phases[race.Phase.LastSpurt],
]);
const sectionX = $derived([0, ...racecourse.SECTIONS.map((s) => course.distance * s.endRate)]);
const drawTitles = $derived(draw.map((d) => d.fy));
const makeChart: Attachment = (el) => {
$effect(() => {
el?.firstChild?.remove();
el?.append(
Plot.plot({
width: width,
height,
clip: true,
title,
marginLeft: 50,
x: {
domain: [0, course.distance],
ticks: phaseX,
},
fy: {
label: null,
padding: 0,
domain: drawTitles,
},
...plotOptions,
marks: [
Plot.barX(draw, {
x1: 'x1',
x2: 'x2',
fy: 'fy',
fill: 'color',
fillOpacity: 0.8,
title: 'label',
}),
Plot.text(draw, { x: 'x1', fy: 'fy', text: 'label', dx: 2, textAnchor: 'start', fontSize: 16, fill: 'black' }),
Plot.ruleX(sectionX, { opacity: 0.25 }),
Plot.ruleX([phases[0], phases[1]], { opacity: 0.5 }),
],
}),
);
});
};
</script>
<div bind:clientWidth={width} bind:clientHeight={height} class={['flex h-full w-full flex-col md:flex-row', className]}>
<div role="img" {@attach makeChart}>
<span>the chart seems to have didn't</span>
</div>
</div>
+145
View File
@@ -0,0 +1,145 @@
import * as z from 'zod';
import * as race from '$lib/race';
export enum InOut {
None = 1,
Inner = 2,
Outer = 3,
OuterToInner = 4,
}
export enum Turn {
Right = 1,
Left = 2,
RightStretch = 3,
LeftStretch = 4,
}
const Span = z.strictObject({
start: z.number(),
end: z.number(),
straight: z.literal([1, 2]).optional(),
corner: z.literal([1, 2, 3, 4]).optional(),
});
export type Span = z.infer<typeof Span>;
const Slope = z.strictObject({
per: z.int32(),
start: z.number(),
end: z.number(),
});
export type Slope = z.infer<typeof Slope>;
export const Course = z.strictObject({
race_course_id: z.int32(),
race_track_id: z.int32(),
race_track_name: z.string(),
distance: z.int32(),
ground: z.enum(race.Surface),
inout: z.enum(InOut),
turn: z.enum(Turn),
lane_max: z.int32(),
threshold1: z.enum(race.Stat).optional(),
threshold2: z.enum(race.Stat).optional(),
lane_start: z.array(z.int32()).length(18),
spans: z.array(Span),
slopes: z.array(Slope),
move_lane_point: z.number(),
});
export type Course = z.infer<typeof Course>;
export async function racecourses(): Promise<Course[]> {
const resp = await fetch('/api/global/racecourse');
return Course.array().parseAsync(await resp.json());
}
export interface SectionInfo {
section: number;
phase: race.Phase;
endRate: number;
posKeep?: boolean;
rush?: boolean;
spotStruggleEnter?: boolean;
}
export const SECTIONS: ReadonlyArray<SectionInfo> = [
{ section: 1, phase: race.Phase.EarlyRace, endRate: 1 / 24, posKeep: true },
{ section: 2, phase: race.Phase.EarlyRace, endRate: 2 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 3, phase: race.Phase.EarlyRace, endRate: 3 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 4, phase: race.Phase.EarlyRace, endRate: 4 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 5, phase: race.Phase.MidRace, endRate: 5 / 24, posKeep: true, rush: true, spotStruggleEnter: true },
{ section: 6, phase: race.Phase.MidRace, endRate: 6 / 24, posKeep: true, rush: true },
{ section: 7, phase: race.Phase.MidRace, endRate: 7 / 24, posKeep: true, rush: true },
{ section: 8, phase: race.Phase.MidRace, endRate: 8 / 24, posKeep: true, rush: true },
{ section: 9, phase: race.Phase.MidRace, endRate: 9 / 24, posKeep: true, rush: true },
{ section: 10, phase: race.Phase.MidRace, endRate: 10 / 24, posKeep: true },
{ section: 11, phase: race.Phase.MidRace, endRate: 11 / 24 },
{ section: 12, phase: race.Phase.MidRace, endRate: 12 / 24 },
{ section: 13, phase: race.Phase.MidRace, endRate: 13 / 24 },
{ section: 14, phase: race.Phase.MidRace, endRate: 14 / 24 },
{ section: 15, phase: race.Phase.MidRace, endRate: 15 / 24 },
{ section: 16, phase: race.Phase.MidRace, endRate: 16 / 24 },
{ section: 17, phase: race.Phase.LateRace, endRate: 17 / 24 },
{ section: 18, phase: race.Phase.LateRace, endRate: 18 / 24 },
{ section: 19, phase: race.Phase.LateRace, endRate: 19 / 24 },
{ section: 20, phase: race.Phase.LateRace, endRate: 20 / 24 },
{ section: 21, phase: race.Phase.LastSpurt, endRate: 21 / 24 },
{ section: 22, phase: race.Phase.LastSpurt, endRate: 22 / 24 },
{ section: 23, phase: race.Phase.LastSpurt, endRate: 23 / 24 },
{ section: 24, phase: race.Phase.LastSpurt, endRate: 24 / 24 },
];
const dirtOnly = [10101, 10103, 10104, 10105];
export function referName(course: Course): string {
const parts = [course.race_track_name, course.distance.toString()];
if (course.ground === race.Surface.Dirt && !dirtOnly.includes(course.race_track_id)) {
parts.push('Dirt');
}
switch (course.inout) {
case InOut.None:
break;
case InOut.Inner:
parts.push('(Inner)');
break;
case InOut.Outer:
parts.push('(Outer)');
break;
case InOut.OuterToInner:
parts.push('(Outer→Inner)');
break;
}
return parts.join(' ');
}
export function fillSlopes(slopes: Slope[], distance: number): Slope[] {
const r: Slope[] = [{ start: 0, end: slopes[0]?.start ?? distance, per: 0 }];
for (const [i, s] of slopes.entries()) {
r.push(s);
r.push({ start: s.end, end: slopes[i + 1]?.start ?? distance, per: 0 });
}
return r.filter((s) => s.start < s.end);
}
export enum DistanceType {
Sprint = 1,
Mile = 2,
Medium = 3,
Long = 4,
}
export function distanceType(course: Course): DistanceType {
if (course.distance < 1500) {
return DistanceType.Sprint;
}
if (course.distance < 1900) {
return DistanceType.Mile;
}
if (course.distance < 2500) {
return DistanceType.Medium;
}
return DistanceType.Long;
}
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from 'vitest';
import * as condition from './condition';
test.for([
{
c: '',
want: [],
},
{
c: 'all_corner_random==1',
want: [[{ type: 'all_corner_random', op: '==', arg: 1 }]],
},
{
c: 'corner!=0',
want: [[{ type: 'corner', op: '!=', arg: 0 }]],
},
{
c: 'order_rate<80',
want: [[{ type: 'order_rate', op: '<', arg: 80 }]],
},
{
c: 'distance_diff_top<=5',
want: [[{ type: 'distance_diff_top', op: '<=', arg: 5 }]],
},
{
c: 'motivation>=4',
want: [[{ type: 'motivation', op: '>=', arg: 4 }]],
},
{
c: 'compete_fight_count>0',
want: [[{ type: 'compete_fight_count', op: '>', arg: 0 }]],
},
{
c: 'phase==1&change_order_onetime<0',
want: [
[
{ type: 'phase', op: '==', arg: 1 },
{ type: 'change_order_onetime', op: '<', arg: 0 },
],
],
},
{
c: 'temptation_count==0&remain_distance<=201&remain_distance>=199&order>=4&order_rate<=70',
want: [
[
{ type: 'temptation_count', op: '==', arg: 0 },
{ type: 'remain_distance', op: '<=', arg: 201 },
{ type: 'remain_distance', op: '>=', arg: 199 },
{ type: 'order', op: '>=', arg: 4 },
{ type: 'order_rate', op: '<=', arg: 70 },
],
],
},
{
c: 'track_id==10001@track_id==10002@track_id==10004@track_id==10010@track_id==10103@track_id==10104',
want: [
[{ type: 'track_id', op: '==', arg: 10001 }],
[{ type: 'track_id', op: '==', arg: 10002 }],
[{ type: 'track_id', op: '==', arg: 10004 }],
[{ type: 'track_id', op: '==', arg: 10010 }],
[{ type: 'track_id', op: '==', arg: 10103 }],
[{ type: 'track_id', op: '==', arg: 10104 }],
],
},
{
c: 'distance_type==1&phase>=2&order_rate>=50&is_overtake==1@distance_type==2&phase>=2&order_rate>=50&is_overtake==1',
want: [
[
{ type: 'distance_type', op: '==', arg: 1 },
{ type: 'phase', op: '>=', arg: 2 },
{ type: 'order_rate', op: '>=', arg: 50 },
{ type: 'is_overtake', op: '==', arg: 1 },
],
[
{ type: 'distance_type', op: '==', arg: 2 },
{ type: 'phase', op: '>=', arg: 2 },
{ type: 'order_rate', op: '>=', arg: 50 },
{ type: 'is_overtake', op: '==', arg: 1 },
],
],
},
])('$c', ({ c, want }) => expect(condition.parse(c)).toEqual(want));
+344
View File
@@ -0,0 +1,344 @@
import * as race from '$lib/race';
import * as racecourse from '$lib/data/racecourse';
export enum Op {
Eq = '==',
Ne = '!=',
Le = '<=',
Lt = '<',
Ge = '>=',
Gt = '>',
}
export interface Condition {
type: string;
op: Op;
arg: number;
}
const re = /(?<type>[a-z0-9_]+)(?<op>[=!<>]=?)(?<arg>-?\d+)/;
function parseCond(conj: string): Condition {
// Using ! assertions because we want to explode if the parse fails.
const r = re.exec(conj)!.groups!;
return { type: r.type, op: r.op as Op, arg: parseInt(r.arg) };
}
export function parse(cond: string): Condition[][] {
if (cond === '') {
return [];
}
return cond.split('@').map((disj) => disj.split('&').map(parseCond));
}
export enum SpanType {
Never = 'N/A',
Random = 'Random',
Skill = 'Triggered',
Whenever = 'Immediate',
}
export interface Span {
start: number;
end: number;
type: SpanType;
}
function coursespans(spans: racecourse.Span[] | racecourse.Slope[], type: SpanType): Span[] {
return or(spans.map((s) => ({ start: s.start, end: s.end, type })));
}
function phase(course: racecourse.Course, op: Op, phase: race.Phase, type: SpanType): Span {
const p = race.phases(course.distance);
switch (op) {
case Op.Eq:
if (phase === race.Phase.EarlyRace) {
return { start: 0, end: p[race.Phase.EarlyRace], type };
}
return { start: p[phase - 1 as race.Phase], end: p[phase], type };
case Op.Le:
return { start: 0, end: p[phase], type };
case Op.Ge:
if (phase === race.Phase.EarlyRace) {
return { start: 0, end: course.distance, type };
}
return { start: p[phase - 1 as race.Phase], end: course.distance, type };
default:
// Other operators aren't used.
throw new Error("weird operator on phase condition");
}
}
function endafter(course: racecourse.Course, type: SpanType): Span {
return phase(course, Op.Ge, race.Phase.LateRace, type);
}
function is_finalcorner(course: racecourse.Course, toEnd: boolean, type: SpanType): Span {
const c = course.spans.findLast((s) => s.corner != null);
if (c == null) {
return { start: 0, end: 0, type: SpanType.Never };
}
return { start: c.start, end: toEnd ? course.distance : c.end, type };
}
function laststraight(course: racecourse.Course, type: SpanType): Span {
const s = course.spans[course.spans.length - 1];
return { start: s.start, end: s.end, type };
}
function distrate(course: racecourse.Course, cond: Condition, type: SpanType): Span {
switch (cond.op) {
case Op.Le:
case Op.Lt:
return { start: 0, end: Math.round(course.distance * cond.arg / 100), type };
case Op.Ge:
case Op.Gt:
return { start: Math.round(course.distance * cond.arg / 100), end: course.distance, type };
}
return { start: 0, end: 0, type: SpanType.Never };
}
function remaining(course: racecourse.Course, cond: Condition, type: SpanType): Span {
switch (cond.op) {
case Op.Eq:
return { start: course.distance - cond.arg - 1, end: course.distance - cond.arg + 1, type };
case Op.Ne:
throw new Error("weird operator on remaining distance");
case Op.Le:
case Op.Lt:
return { start: course.distance - cond.arg, end: course.distance, type };
case Op.Ge:
case Op.Gt:
return { start: 0, end: course.distance - cond.arg, type };
}
}
function later(s: Span, p: number): Span {
return { ...s, start: (s.start + s.end) * p };
}
function first(s: Span, p: number): Span {
return { ...s, end: (s.start + s.end) * p };
}
function accumulatetime(course: racecourse.Course, t: number, type: SpanType): Span {
return { start: race.baseSpeed(course.distance) * t, end: course.distance, type };
}
function intersection(a: Span, b: Span): Span {
const start = Math.max(a.start, b.start);
const end = Math.min(a.end, b.end);
if (start >= end) {
return { start: 0, end: 0, type: SpanType.Never };
}
switch (a.type) {
case SpanType.Never:
return { start: 0, end: 0, type: SpanType.Never };
case SpanType.Random:
case SpanType.Skill:
if (b.type === SpanType.Never) {
return { start: 0, end: 0, type: SpanType.Never };
}
// We use a.type on the assumption that we will never intersect
// a random span with a skill-triggered span.
return { start, end, type: a.type };
case SpanType.Whenever:
return { start, end, type: b.type };
}
}
function opcompare(cond: Condition, x: number) {
switch (cond.op) {
case Op.Eq:
return x === cond.arg;
case Op.Ne:
return x !== cond.arg;
case Op.Le:
return x <= cond.arg;
case Op.Lt:
return x < cond.arg;
case Op.Ge:
return x >= cond.arg;
case Op.Gt:
return x > cond.arg;
}
}
function must(x: boolean): SpanType {
return x ? SpanType.Whenever : SpanType.Never;
}
const perCond: Readonly<Partial<Record<Condition['type'], (course: racecourse.Course, cond: Condition) => Span[]>>> = {
'accumulatetime': (course, cond) => [accumulatetime(course, cond.arg, SpanType.Whenever)],
'activate_count_all_team': (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
'activate_count_end_after': (course) => [endafter(course, SpanType.Skill)],
'activate_count_heal': (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
'activate_count_later_half': (course) => [{ start: course.distance / 2, end: course.distance, type: SpanType.Skill }],
'activate_count_middle': (course) => [phase(course, Op.Eq, race.Phase.MidRace, SpanType.Skill)],
'activate_count_start': (course) => [phase(course, Op.Eq, race.Phase.EarlyRace, SpanType.Skill)],
'all_corner_random': (course) => coursespans(course.spans.filter((s) => s.corner != null), SpanType.Random),
'change_order_up_end_after': (course) => [endafter(course, SpanType.Whenever)],
'change_order_up_finalcorner_after': (course) => [is_finalcorner(course, true, SpanType.Whenever)],
'change_order_up_middle': (course) => [phase(course, Op.Eq, race.Phase.MidRace, SpanType.Whenever)],
'compete_fight_count': (course) => [laststraight(course, SpanType.Whenever)],
'corner': (course, cond) => coursespans(course.spans.filter((s) => opcompare(cond, s.corner ?? 0)), SpanType.Whenever),
'corner_count': (course, cond) => [{ start: 0, end: course.distance, type: opcompare(cond, course.spans.reduce((n, s) => n + (s.corner != null ? 1 : 0), 0)) ? SpanType.Never : SpanType.Whenever }],
'corner_random': (course, cond) => {
const c = course.spans.findLast((s) => s.corner === cond.arg);
if (c == null) {
return [{ start: 0, end: 0, type: SpanType.Never }];
}
return [{ start: c.start, end: c.end, type: SpanType.Random }];
},
'course_distance': (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.distance)) }],
'distance_rate': (course, cond) => [distrate(course, cond, SpanType.Whenever)],
'distance_rate_after_random': (course, cond) => [distrate(course, cond, SpanType.Random)],
'distance_type': (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, racecourse.distanceType(course))) }],
'down_slope_random': (course) => coursespans(course.slopes.filter((s) => s.per < 0), SpanType.Random),
'furlong': (course, cond) => {
switch (cond.op) {
case Op.Eq:
return [{ start: cond.arg * 201.168, end: (cond.arg + 1) * 201.168, type: SpanType.Whenever }];
case Op.Ne:
return [
{ start: 0, end: cond.arg * 201.168, type: SpanType.Whenever },
{ start: (cond.arg + 1) * 201.168, end: course.distance, type: SpanType.Whenever },
];
case Op.Le:
case Op.Lt:
return [{ start: 0, end: (cond.arg + 1) * 201.168, type: SpanType.Whenever }];
case Op.Ge:
case Op.Gt:
return [{ start: cond.arg * 201.168, end: course.distance, type: SpanType.Whenever }];
}
},
'ground_type': (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.ground)) }],
// hp_per would be an interesting challenge...
'is_finalcorner': (course, cond) => {
// Either is_finalcorner==1 or is_finalcorner==0.
if (cond.arg === 1) {
return [is_finalcorner(course, true, SpanType.Whenever)];
}
const c = course.spans.findLast((s) => s.corner != null);
if (c == null) {
return [{ start: 0, end: course.distance, type: SpanType.Whenever }];
}
return [{ start: 0, end: c.start, type: SpanType.Whenever }];
},
// TODO(zeph): is_abroad
'is_activate_any_skill': (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
'is_activate_heal_skill': (course) => [{ start: 0, end: course.distance, type: SpanType.Skill }],
'is_basis_distance': (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.distance % 400 === 0 ? 1 : 0)) }],
'is_dirtgrade': (course, cond) => {
// NAR tracks: Ooi, Kawasaki, Funabashi, Morioka
const is = [10101, 10103, 10104, 10105].includes(course.race_track_id);
return [{ start: 0, end: course.distance, type: must(opcompare(cond, is ? 1 : 0)) }];
},
'is_finalcorner_laterhalf': (course) => [later(is_finalcorner(course, false, SpanType.Whenever), 0.5)],
'is_finalcorner_random': (course) => [is_finalcorner(course, false, SpanType.Random)],
'is_last_straight': (course) => [laststraight(course, SpanType.Whenever)],
'is_last_straight_onetime': (course) => [first(laststraight(course, SpanType.Whenever), 0.02)],
'is_lastspurt': (course) => [endafter(course, SpanType.Whenever)],
// could implement is_temptation!=0, but that condition isn't used
'is_tight_track': (course, cond) => {
// Sapporo, Hakodate, Fukushima, Kokura, Kawasaki, Funabashi
// TODO(zeph): GameTora says "afaik" for this list...
const is = [10001, 10002, 10004, 10010, 10103, 10104];
return [{ start: 0, end: course.distance, type: must(opcompare(cond, is ? 1 : 0)) }];
},
'last_straight_random': (course) => [laststraight(course, SpanType.Random)],
'lastspurt': (course) => [endafter(course, SpanType.Whenever)],
'order_rate_in20_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_in40_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_in50_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_in80_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_out20_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_out40_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_out50_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'order_rate_out70_continue': (course) => [accumulatetime(course, 5, SpanType.Whenever)],
'phase': (course, cond) => [phase(course, cond.op, cond.arg, SpanType.Whenever)],
'phase_corner_random': (course, cond) => {
const p = phase(course, Op.Eq, cond.arg, SpanType.Random);
return coursespans(course.spans.filter((s) => s.corner != null), SpanType.Random).map((s) => intersection(s, p));
},
'phase_first_half_straight_random': (course, cond) => {
const p = first(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5);
return coursespans(course.spans.filter((s) => s.straight != null), SpanType.Random).map((s) => intersection(s, p));
},
'phase_firsthalf': (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Whenever), 0.5)],
'phase_firsthalf_random': (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5)],
'phase_firstquarter': (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Whenever), 0.25)],
'phase_firstquarter_random': (course, cond) => [first(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.25)],
'phase_laterhalf': (course, cond) => [later(phase(course, Op.Eq, cond.arg, SpanType.Whenever), 0.5)],
'phase_laterhalf_random': (course, cond) => [later(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5)],
'phase_latter_half_straight_random': (course, cond) => {
const p = later(phase(course, Op.Eq, cond.arg, SpanType.Random), 0.5);
return coursespans(course.spans.filter((s) => s.straight != null), SpanType.Random).map((s) => intersection(s, p));
},
'phase_random': (course, cond) => [phase(course, cond.op, cond.arg, SpanType.Random)],
'phase_straight_random': (course, cond) => {
const p = phase(course, Op.Eq, cond.arg, SpanType.Random);
return coursespans(course.spans.filter((s) => s.straight != null), SpanType.Random).map((s) => intersection(s, p));
},
'remain_distance': (course, cond) => [remaining(course, cond, SpanType.Whenever)],
'remain_distance_viewer_id': (course, cond) => [remaining(course, cond, SpanType.Whenever)],
'rotation': (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.turn)) }],
'run_at_full_speed_random': (course) => [endafter(course, SpanType.Random)],
'slope': (course, cond) => {
switch (cond.arg) {
case 0:
return coursespans(racecourse.fillSlopes(course.slopes, course.distance).filter((s) => s.per === 0), SpanType.Whenever);
case 1:
return coursespans(course.slopes.filter((s) => s.per > 0), SpanType.Whenever);
case 2:
return coursespans(course.slopes.filter((s) => s.per < 0), SpanType.Whenever);
default:
throw new Error("weird argument to slope condition");
}
},
'straight_front_type': (course, cond) => coursespans(course.spans.filter((s) => s.straight === cond.arg), SpanType.Whenever),
'straight_random': (course) => coursespans(course.spans.filter((s) => s.straight != null), SpanType.Random),
'track_id': (course, cond) => [{ start: 0, end: course.distance, type: must(opcompare(cond, course.race_track_id)) }],
'up_slope_random': (course) => coursespans(course.slopes.filter((s) => s.per > 0), SpanType.Random),
'up_slope_random_later_half': (course) => {
const p = { start: course.distance / 2, end: course.distance, type: SpanType.Random };
return coursespans(course.slopes.filter((s) => s.per > 0), SpanType.Random).map((s) => intersection(s, p));
}
} as const;
export function spans(course: racecourse.Course, cond: Condition): Span[] {
const f = perCond[cond.type];
return f?.(course, cond) ?? [{ start: 0, end: course.distance, type: SpanType.Whenever }];
}
export function and(as: Span[], bs: Span[]): Span[] {
const a = as.toSorted((l, r) => l.start - r.start);
const b = bs.toSorted((l, r) => l.start - r.start);
// TODO(zeph): this is quadratic in the number of spans; we could do it linear
return a.flatMap((u) => b.map((v) => intersection(u, v)).filter((v) => v.type !== SpanType.Never));
}
export function or(spans: Span[]): Span[] {
const a = spans.filter((s) => s.type !== SpanType.Never).sort((l, r) => l.start - r.start);
if (spans.length === 0) {
return [];
}
const r: Span[] = [];
let cur = a.shift()!;
while (a.length > 0) {
const next = a.shift()!;
// If they're disjoint or different types, they can't be merged.
// Note that we only need to check next start versus current end
// because we sort by start position first.
if (next.start > cur.end || next.type !== cur.type) {
r.push(cur);
cur = next;
continue;
}
// The current start is always the minimum of the two because we sorted,
// and the merged type is the same as the current type.
cur.end = Math.max(cur.end, next.end);
}
r.push(cur);
return r;
}
@@ -0,0 +1,245 @@
<script lang="ts">
import CourseChart from '$lib/CourseChart.svelte';
import * as racecourse from '$lib/data/racecourse';
import * as skill from '$lib/data/skill';
import * as condition from '$lib/racelib/condition';
import Pick from '$lib/Pick.svelte';
import SkillIcon from '$lib/SkillIcon.svelte';
import { onMount } from 'svelte';
import TopChart from './TopChart.svelte';
let courses: racecourse.Course[] = $state([]);
let skills: skill.Skill[] = $state([]);
onMount(async () => {
const [c, sk] = await Promise.all([racecourse.racecourses(), skill.skills()]);
courses = c;
skills = sk;
});
let course: racecourse.Course | undefined = $state();
let selSkills: skill.Skill[] = $state([]);
const spanColors: Readonly<Record<condition.SpanType, string>> = {
[condition.SpanType.Never]: 'red',
[condition.SpanType.Random]: 'yellow',
[condition.SpanType.Skill]: 'cyan',
[condition.SpanType.Whenever]: 'lime',
} as const;
const drawSkills = $derived(
selSkills.map((skill) => {
if (course == null) {
return { skill, draw: [] };
}
const cs =
skill.activations.length === 1
? [
{ fy: 'Pre', parse: condition.parse(skill.activations[0].precondition ?? '') },
{ fy: 'Trigger', parse: condition.parse(skill.activations[0].condition) },
]
: [
{ fy: 'Pre1', parse: condition.parse(skill.activations[0].precondition ?? '') },
{ fy: 'Trigger1', parse: condition.parse(skill.activations[0].condition) },
{ fy: 'Pre2', parse: condition.parse(skill.activations[1].precondition ?? '') },
{ fy: 'Trigger2', parse: condition.parse(skill.activations[1].condition) },
];
return {
skill,
draw: cs
.filter((c) => c.parse.length > 0)
.flatMap(({ fy, parse }) => {
const p = parse
.map((conj) => conj.map((c) => condition.spans(course!, c)).reduce(condition.and))
.flatMap((spans) =>
spans
.filter((sp) => sp != null)
.map((sp) => ({ fy, x1: sp.start, x2: sp.end, color: spanColors[sp.type], label: sp.type })),
);
if (p.length === 0) {
return [{ fy, x1: 0, x2: course?.distance ?? 0, label: 'N/A', color: spanColors[condition.SpanType.Never] }];
}
return p;
}),
};
}),
);
function courseKey(c: racecourse.Course) {
return { id: c.race_course_id, name: racecourse.referName(c) };
}
function skillKey(sk: skill.Skill) {
return { id: sk.skill_id, name: sk.name };
}
let skillToAdd: skill.Skill | undefined = $state();
function addSkill() {
if (skillToAdd == null) {
return;
}
selSkills.push(skillToAdd);
}
function delSkill(i: number) {
if (i < 0 || i >= selSkills.length) {
return;
}
selSkills.splice(i, 1);
}
function swapSkill(i: number, dir: number) {
const j = i + dir;
if (j < 0 || j >= selSkills.length) {
return;
}
const [a, b] = [selSkills[i], selSkills[j]];
[selSkills[i], selSkills[j]] = [b, a];
}
</script>
{#snippet showcond(cond: string)}
{@const disj = cond.replaceAll('&', ' & ').split('@')}
{#each disj as conj, i (conj)}
<span class="block font-mono text-sm">{conj}</span>
{#if i !== disj.length - 1}
<span class="block text-xs italic">or</span>
{/if}
{/each}
{/snippet}
<h1 class="text-4xl">Skill Activation Zones</h1>
<form class="mx-auto mt-8 rounded-md p-4 text-center shadow-md ring md:max-w-2xl">
<Pick id="course" items={courses} key={courseKey} class="w-full" bind:value={course} required />
</form>
<svelte:boundary>
<div class="mx-auto mt-8 flex max-w-6xl flex-col text-center">
<div class="h-40">
{#if course != null}
<TopChart {course} />
{/if}
</div>
{#each drawSkills as { skill, draw }, i (i)}
<div class="h-28 border-t">
{#if course != null}
<CourseChart {course} {draw} />
{/if}
</div>
<div class="flex flex-row items-center gap-2">
<SkillIcon icon_id={skill.icon_id} size={32} />
<Pick id={`skill${i}`} class="w-64" items={skills} key={skillKey} bind:value={selSkills[i]} required>
{#snippet option(sk)}
<span>
<SkillIcon icon_id={sk.icon_id} size={16} />
{sk.name}
</span>
{/snippet}
</Pick>
<div class="flex grow flex-col items-center">
{#each skill.activations as act, i (i)}
{#if skill.activations.length > 1}
<span class="block text-xs italic">Activation {i + 1}</span>
{/if}
{#if act.precondition}
<span class="block text-sm italic">Precondition</span>
{@render showcond(act.precondition)}
<span class="block text-sm italic">Condition</span>
{/if}
{@render showcond(act.condition)}
{/each}
</div>
<div class="flex shrink flex-row items-stretch gap-1 px-2 pb-4">
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900 pointer-coarse:hidden"
onclick={() => swapSkill(i, -1)}
>
&uparrow;
</button>
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900"
onclick={() => delSkill(i)}
>
&times;
</button>
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900 pointer-coarse:hidden"
onclick={() => swapSkill(i, 1)}
>
&downarrow;
</button>
</div>
</div>
{/each}
<div class="flex items-center-safe justify-center-safe gap-2 border-t pt-8">
<Pick id="addSkill" class="w-64" items={skills} key={skillKey} bind:value={skillToAdd} required>
{#snippet option(sk)}
<span>
<SkillIcon icon_id={sk.icon_id} size={16} />
{sk.name}
</span>
{/snippet}
</Pick>
<button
type="button"
class="my-auto h-8 w-8 flex-none rounded-full bg-mist-300 text-2xl hover:cursor-pointer dark:bg-mist-900"
onclick={addSkill}
>
+
</button>
</div>
</div>
<div class="mx-auto mt-12 w-full max-w-4xl border-t pt-4 md:mt-8">
<p>This calculator separates conditions into four categories:</p>
<ul class="mb-4 list-disc pl-4">
<li>
Immediate conditions that are satisfied continuously during their range. The skill activates as soon as all other
conditions are satisfied. These conditions are mapped in <div
class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[lime] align-middle"
>
&nbsp;
</div>
green with an "Immediate" label.
</li>
<li>
Random conditions that are satisfied at a single point within their range, or several points in the case of e.g. <span
class="font-mono">all_corner_random==1</span
>. All other conditions must be satisfied at the time the horse reaches the random points for the skill to activate. Other
conditions may exclude portions of the random range, which can cause them to become impossible to trigger; this is
particularly common with <span class="font-mono">phase_random==0&nbsp;&amp;&nbsp;accumulatetime&gt;=5</span> skills. These
conditions are mapped in
<div class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[yellow] align-middle">&nbsp;</div>
yellow with a "Random" label.
</li>
<li>
Conditions satisfied by the Uma's other skills triggering. These are mapped in <div
class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[cyan] align-middle"
>
&nbsp;
</div>
cyan with a "Triggered" label.
</li>
<li>
Unsatisfiable conditions. These are mapped in <div
class="inline-block h-4 max-h-4 min-h-4 w-4 max-w-4 min-w-4 border bg-[red] align-middle"
>
&nbsp;
</div>
red with an "N/A" label.
</li>
</ul>
<p>
<span class="font-mono">accumulatetime</span> is implemented by assuming the horse runs at the
<a
href="https://docs.google.com/document/d/15VzW9W2tXBBTibBRbZ8IVpW6HaMX8H0RP03kq6Az7Xg/edit?tab=t.0#heading=h.q3a514loz268"
target="_blank"
rel="noopener noreferrer"
>
race base speed
</a>
from the gate. In practice, ignoring the start dash this way will tend to overestimate the starting position of the condition
especially for backline running styles, and it may underestimate it for runaways with full gate accels.
</p>
</div>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>
@@ -0,0 +1,53 @@
<script lang="ts">
import * as race from '$lib/race';
import * as racecourse from '$lib/data/racecourse';
import CourseChart from '$lib/CourseChart.svelte';
interface Props {
course: racecourse.Course;
}
let { course }: Props = $props();
function slopeprops(s: racecourse.Slope) {
if (s.per > 0) {
return { fy: 'Slope', x1: s.start, x2: s.end, label: `+${s.per / 10000}`, color: 'white' };
}
if (s.per < 0) {
return { fy: 'Slope', x1: s.start, x2: s.end, label: `${s.per / 10000}`, color: 'cyan' };
}
return { fy: 'Slope', x1: s.start, x2: s.end, color: 'none' };
}
function spanprops(s: racecourse.Span) {
if (s.corner != null) {
if (s.corner === 4) {
return { fy: 'Span', x1: s.start, x2: s.end, label: 'C4', color: 'red' };
}
return { fy: 'Span', x1: s.start, x2: s.end, label: `C${s.corner}`, color: 'cyan' };
}
if (s.straight != null) {
return { fy: 'Span', x1: s.start, x2: s.end, label: `S${s.straight}`, color: 'white' };
}
return { fy: 'Span', x1: s.start, x2: s.end, label: '~', color: 'pink' };
}
const poskeep = $derived((course.distance * 10) / 24);
const spotStruggle = $derived((course.distance * 9) / 24);
const rushStart = $derived(course.distance / 24);
const rushEnd = $derived((course.distance * 10) / 24);
const slopes = $derived(racecourse.fillSlopes(course.slopes, course.distance));
const phases = $derived(race.phases(course.distance));
const draw = $derived([
...slopes.map(slopeprops),
...course.spans.map(spanprops),
{ fy: 'Phase', x1: 0, x2: phases[race.Phase.EarlyRace], label: 'Early', color: 'cyan' },
{ fy: 'Phase', x1: phases[race.Phase.EarlyRace], x2: phases[race.Phase.MidRace], label: 'Mid', color: 'yellow' },
{ fy: 'Phase', x1: phases[race.Phase.MidRace], x2: phases[race.Phase.LateRace], label: 'Late', color: 'magenta' },
{ fy: 'Phase', x1: phases[race.Phase.LateRace], x2: phases[race.Phase.LastSpurt], label: 'Spurt', color: 'magenta' },
{ fy: 'PosKeep', x1: 0, x2: poskeep, color: 'purple' },
{ fy: 'SpotS', x1: 150, x2: spotStruggle, color: 'green' },
{ fy: 'Rush', x1: rushStart, x2: rushEnd, color: 'red' },
]);
</script>
<CourseChart {course} {draw} fontColor="black" />