diff --git a/zenno/src/lib/CourseChart.svelte b/zenno/src/lib/CourseChart.svelte new file mode 100644 index 0000000..46d5aa1 --- /dev/null +++ b/zenno/src/lib/CourseChart.svelte @@ -0,0 +1,85 @@ + + +
+
+ the chart seems to have didn't +
+
diff --git a/zenno/src/lib/data/racecourse.ts b/zenno/src/lib/data/racecourse.ts new file mode 100644 index 0000000..4b6251f --- /dev/null +++ b/zenno/src/lib/data/racecourse.ts @@ -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; + +const Slope = z.strictObject({ + per: z.int32(), + start: z.number(), + end: z.number(), +}); + +export type Slope = z.infer; + +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; + +export async function racecourses(): Promise { + 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 = [ + { 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; +} diff --git a/zenno/src/lib/racelib/condition.spec.ts b/zenno/src/lib/racelib/condition.spec.ts new file mode 100644 index 0000000..013f536 --- /dev/null +++ b/zenno/src/lib/racelib/condition.spec.ts @@ -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)); diff --git a/zenno/src/lib/racelib/condition.ts b/zenno/src/lib/racelib/condition.ts new file mode 100644 index 0000000..9c742d0 --- /dev/null +++ b/zenno/src/lib/racelib/condition.ts @@ -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 = /(?[a-z0-9_]+)(?[=!<>]=?)(?-?\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 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; +} diff --git a/zenno/src/routes/race/skillzone/+page.svelte b/zenno/src/routes/race/skillzone/+page.svelte new file mode 100644 index 0000000..7e5b644 --- /dev/null +++ b/zenno/src/routes/race/skillzone/+page.svelte @@ -0,0 +1,245 @@ + + +{#snippet showcond(cond: string)} + {@const disj = cond.replaceAll('&', ' & ').split('@')} + {#each disj as conj, i (conj)} + {conj} + {#if i !== disj.length - 1} + or + {/if} + {/each} +{/snippet} + +

Skill Activation Zones

+
+ + + +
+
+ {#if course != null} + + {/if} +
+ {#each drawSkills as { skill, draw }, i (i)} +
+ {#if course != null} + + {/if} +
+
+ + + {#snippet option(sk)} + + + {sk.name} + + {/snippet} + +
+ {#each skill.activations as act, i (i)} + {#if skill.activations.length > 1} + Activation {i + 1} + {/if} + {#if act.precondition} + Precondition + {@render showcond(act.precondition)} + Condition + {/if} + {@render showcond(act.condition)} + {/each} +
+
+ + + +
+
+ {/each} +
+ + {#snippet option(sk)} + + + {sk.name} + + {/snippet} + + +
+
+
+

This calculator separates conditions into four categories:

+
    +
  • + 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
    +   +
    + green with an "Immediate" label. +
  • +
  • + Random conditions that are satisfied at a single point within their range, or several points in the case of e.g. all_corner_random==1. 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 phase_random==0 & accumulatetime>=5 skills. These + conditions are mapped in +
     
    + yellow with a "Random" label. +
  • +
  • + Conditions satisfied by the Uma's other skills triggering. These are mapped in
    +   +
    + cyan with a "Triggered" label. +
  • +
  • + Unsatisfiable conditions. These are mapped in
    +   +
    + red with an "N/A" label. +
  • +
+

+ accumulatetime is implemented by assuming the horse runs at the + + race base speed + + 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. +

+
+ + {#snippet pending()} +
Loading...
+ {/snippet} +
diff --git a/zenno/src/routes/race/skillzone/TopChart.svelte b/zenno/src/routes/race/skillzone/TopChart.svelte new file mode 100644 index 0000000..10d2ebc --- /dev/null +++ b/zenno/src/routes/race/skillzone/TopChart.svelte @@ -0,0 +1,53 @@ + + +