From 36d2cf8d32fb1f577cce78ae7a08ecd3d5f8f0a6 Mon Sep 17 00:00:00 2001 From: Boxel Submission Bot Date: Wed, 25 Mar 2026 16:05:09 +0800 Subject: [PATCH] add Soccer Match Panel with Live Score and Events changes [boxel-content-hash:b6a9baa9900a] --- .../30338f0a-59fa-4615-9eda-9b6d97b5a2d9.json | 94 ++ .../431e9ffb-8c9b-4f74-99a2-bc0b7200a57b.json | 60 ++ SoccerMatch/blue-city-vs-red-united.json | 60 ++ .../6ec593ab-4d20-44ad-8928-2b69e9a3eecc.json | 40 + .../93ab4d20-54ad-4928-ab69-e9a3eecc6129.json | 40 + .../a86ec593-ab4d-4054-ad49-282b69e9a3ee.json | 40 + .../ab4d2054-ad49-482b-a9e9-a3eecc6129d2.json | 40 + .../c593ab4d-2054-4d49-a82b-69e9a3eecc61.json | 40 + soccer-match.gts | 986 ++++++++++++++++++ 9 files changed, 1400 insertions(+) create mode 100644 CardListing/30338f0a-59fa-4615-9eda-9b6d97b5a2d9.json create mode 100644 SoccerMatch/431e9ffb-8c9b-4f74-99a2-bc0b7200a57b.json create mode 100644 SoccerMatch/blue-city-vs-red-united.json create mode 100644 Spec/6ec593ab-4d20-44ad-8928-2b69e9a3eecc.json create mode 100644 Spec/93ab4d20-54ad-4928-ab69-e9a3eecc6129.json create mode 100644 Spec/a86ec593-ab4d-4054-ad49-282b69e9a3ee.json create mode 100644 Spec/ab4d2054-ad49-482b-a9e9-a3eecc6129d2.json create mode 100644 Spec/c593ab4d-2054-4d49-a82b-69e9a3eecc61.json create mode 100644 soccer-match.gts diff --git a/CardListing/30338f0a-59fa-4615-9eda-9b6d97b5a2d9.json b/CardListing/30338f0a-59fa-4615-9eda-9b6d97b5a2d9.json new file mode 100644 index 0000000..e5144a7 --- /dev/null +++ b/CardListing/30338f0a-59fa-4615-9eda-9b6d97b5a2d9.json @@ -0,0 +1,94 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "CardListing", + "module": "https://realms-staging.stack.cards/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Soccer Match Panel with Live Score and Events", + "images": [], + "summary": "The SoccerMatch card defines a comprehensive model for simulating and visualizing a soccer match. It manages match states, including score, time, stamina, possession, and events. The card tracks detailed match events such as goals, shots, fouls, and other in-game occurrences, maintaining a log for commentary. It also provides tools for running automated minute-by-minute simulations, updating match statistics dynamically, and visualizing momentum trends via sparklines. Embedded components offer various presentation formats like full match view, streamlined fixtures, and live commentary, making it suitable for interactive soccer match play, analysis, and storytelling within an application.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "tags.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/140feda8-625b-4a24-9ddb-6f4da891aef2" + } + }, + "tags.1": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4" + } + }, + "skills": { + "links": { + "self": null + } + }, + "license": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "specs.0": { + "links": { + "self": "../Spec/a86ec593-ab4d-4054-ad49-282b69e9a3ee" + } + }, + "specs.1": { + "links": { + "self": "../Spec/6ec593ab-4d20-44ad-8928-2b69e9a3eecc" + } + }, + "specs.2": { + "links": { + "self": "../Spec/c593ab4d-2054-4d49-a82b-69e9a3eecc61" + } + }, + "specs.3": { + "links": { + "self": "../Spec/93ab4d20-54ad-4928-ab69-e9a3eecc6129" + } + }, + "specs.4": { + "links": { + "self": "../Spec/ab4d2054-ad49-482b-a9e9-a3eecc6129d2" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "categories": { + "links": { + "self": null + } + }, + "examples.0": { + "links": { + "self": "../SoccerMatch/blue-city-vs-red-united" + } + }, + "examples.1": { + "links": { + "self": "../SoccerMatch/431e9ffb-8c9b-4f74-99a2-bc0b7200a57b" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/SoccerMatch/431e9ffb-8c9b-4f74-99a2-bc0b7200a57b.json b/SoccerMatch/431e9ffb-8c9b-4f74-99a2-bc0b7200a57b.json new file mode 100644 index 0000000..ea2a436 --- /dev/null +++ b/SoccerMatch/431e9ffb-8c9b-4f74-99a2-bc0b7200a57b.json @@ -0,0 +1,60 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SoccerMatch", + "module": "../soccer-match" + } + }, + "type": "card", + "attributes": { + "score": { + "away": null, + "home": null + }, + "awayTeam": { + "name": null, + "color": null, + "rating": null, + "formation": null + }, + "cardInfo": { + "name": "asdad", + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "homeTeam": { + "name": null, + "color": null, + "rating": null, + "formation": null + }, + "isPaused": false, + "matchLog": [], + "addedTime": null, + "awayShots": { + "shots": null, + "onTarget": null + }, + "homeShots": { + "shots": null, + "onTarget": null + }, + "isFinished": false, + "possession": null, + "awayStamina": null, + "homeStamina": null, + "isSecondHalf": false, + "currentMinute": null, + "momentumSeries": [] + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/SoccerMatch/blue-city-vs-red-united.json b/SoccerMatch/blue-city-vs-red-united.json new file mode 100644 index 0000000..5db7a78 --- /dev/null +++ b/SoccerMatch/blue-city-vs-red-united.json @@ -0,0 +1,60 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SoccerMatch", + "module": "../soccer-match" + } + }, + "type": "card", + "attributes": { + "score": { + "away": 0, + "home": 0 + }, + "awayTeam": { + "name": "Red United", + "color": "#dc2626", + "rating": 82, + "formation": "4-2-3-1" + }, + "cardInfo": { + "notes": null, + "name": null, + "summary": null, + "cardThumbnailURL": null + }, + "homeTeam": { + "name": "Blue City", + "color": "#2563eb", + "rating": 84, + "formation": "4-3-3" + }, + "isPaused": false, + "matchLog": [], + "addedTime": 2, + "awayShots": { + "shots": 0, + "onTarget": 0 + }, + "homeShots": { + "shots": 0, + "onTarget": 0 + }, + "isFinished": false, + "possession": "home", + "awayStamina": 100, + "homeStamina": 100, + "isSecondHalf": false, + "currentMinute": 0, + "momentumSeries": [] + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} diff --git a/Spec/6ec593ab-4d20-44ad-8928-2b69e9a3eecc.json b/Spec/6ec593ab-4d20-44ad-8928-2b69e9a3eecc.json new file mode 100644 index 0000000..40519b2 --- /dev/null +++ b/Spec/6ec593ab-4d20-44ad-8928-2b69e9a3eecc.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../soccer-match", + "name": "MatchEventField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Match Event", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/93ab4d20-54ad-4928-ab69-e9a3eecc6129.json b/Spec/93ab4d20-54ad-4928-ab69-e9a3eecc6129.json new file mode 100644 index 0000000..df1509b --- /dev/null +++ b/Spec/93ab4d20-54ad-4928-ab69-e9a3eecc6129.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../soccer-match", + "name": "ScoreField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Score", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/a86ec593-ab4d-4054-ad49-282b69e9a3ee.json b/Spec/a86ec593-ab4d-4054-ad49-282b69e9a3ee.json new file mode 100644 index 0000000..23bd540 --- /dev/null +++ b/Spec/a86ec593-ab4d-4054-ad49-282b69e9a3ee.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../soccer-match", + "name": "TeamField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Team", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/ab4d2054-ad49-482b-a9e9-a3eecc6129d2.json b/Spec/ab4d2054-ad49-482b-a9e9-a3eecc6129d2.json new file mode 100644 index 0000000..fa0af73 --- /dev/null +++ b/Spec/ab4d2054-ad49-482b-a9e9-a3eecc6129d2.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../soccer-match", + "name": "SoccerMatch" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Soccer Match", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/c593ab4d-2054-4d49-a82b-69e9a3eecc61.json b/Spec/c593ab4d-2054-4d49-a82b-69e9a3eecc61.json new file mode 100644 index 0000000..dee0727 --- /dev/null +++ b/Spec/c593ab4d-2054-4d49-a82b-69e9a3eecc61.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../soccer-match", + "name": "ShotStatsField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Shot Stats", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/soccer-match.gts b/soccer-match.gts new file mode 100644 index 0000000..558293d --- /dev/null +++ b/soccer-match.gts @@ -0,0 +1,986 @@ +import { concat, fn, get } from '@ember/helper'; +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +// ¹ Core API and components +import { + CardDef, + FieldDef, + field, + contains, + containsMany, + linksTo, + linksToMany, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import { + Button, + FieldContainer, + CardContainer, +} from '@cardstack/boxel-ui/components'; +import { + formatNumber, + formatRelativeTime, + subtract, +} from '@cardstack/boxel-ui/helpers'; +import { add, gt, and, or, not } from '@cardstack/boxel-ui/helpers'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { task, restartableTask, timeout } from 'ember-concurrency'; + +// ² FieldDef: Team +export class TeamField extends FieldDef { + static displayName = 'Team'; + @field name = contains(StringField); + @field color = contains(StringField); // hex or CSS color + @field rating = contains(NumberField); // 60-95 team strength + @field formation = contains(StringField); // e.g., 4-3-3 + + static embedded = class Embedded extends Component { + + }; +} + +// ³ FieldDef: Match Event +export class MatchEventField extends FieldDef { + static displayName = 'Match Event'; + @field minute = contains(NumberField); + @field team = contains(StringField); // 'home' | 'away' | 'neutral' + @field eventType = contains(StringField); // goal, shot, foul, yellow, red, save, corner, offside, injury + @field note = contains(StringField); + + static embedded = class Embedded extends Component { + + }; +} + +// ⁴ FieldDef: Shot Stats +export class ShotStatsField extends FieldDef { + static displayName = 'Shot Stats'; + @field shots = contains(NumberField); + @field onTarget = contains(NumberField); + + static atom = class Atom extends Component { + + }; +} + +// ⁵ FieldDef: Score +export class ScoreField extends FieldDef { + static displayName = 'Score'; + @field home = contains(NumberField); + @field away = contains(NumberField); + + static atom = class Atom extends Component { + + }; +} + +// ⁶ CardDef: SoccerMatch +export class SoccerMatch extends CardDef { + // Helpers to manage event log safely + private ensureLog(self: SoccerMatch) { + let log = (self as any).matchLog; + if (!Array.isArray(log)) { + (self as any).matchLog = []; + } + } + + // Sanitize and push a plain event entry into matchLog + private addEvent( + self: SoccerMatch, + ev: + | { + minute?: unknown; + team?: unknown; + eventType?: unknown; + note?: unknown; + } + | null + | undefined, + ) { + if (!ev || typeof ev !== 'object') return; + + // Coerce primitives + const minuteRaw = Number((ev as any).minute); + let minute = Number.isFinite(minuteRaw) + ? minuteRaw + : self.currentMinute ?? 0; + minute = Math.max(0, Math.floor(minute)); + + const teamRaw = (ev as any).team; + const team = + teamRaw === 'home' || teamRaw === 'away' || teamRaw === 'neutral' + ? teamRaw + : self.possession === 'home' || self.possession === 'away' + ? self.possession + : 'neutral'; + + const eventTypeRaw = (ev as any).eventType; + const validTypes = new Set([ + 'kickoff', + 'goal', + 'shot', + 'save', + 'foul', + 'yellow', + 'red', + 'corner', + 'offside', + 'event', + ]); + const eventType = validTypes.has(eventTypeRaw as string) + ? (eventTypeRaw as string) + : 'event'; + + const noteVal = + typeof (ev as any).note === 'string' + ? ((ev as any).note as string) + : undefined; + + // Push plain literal + this.ensureLog(self); + const entry: any = { minute, team, eventType }; + if (noteVal) entry.note = noteVal; + ((self as any).matchLog as any[]).push(entry); + } + static displayName = 'Soccer Match'; + + // Teams and setup + @field homeTeam = contains(TeamField); + @field awayTeam = contains(TeamField); + + // Match time + @field currentMinute = contains(NumberField); // 0..90+ + @field addedTime = contains(NumberField); // 0..7 + @field isSecondHalf = contains(BooleanField); + @field isPaused = contains(BooleanField); + @field isFinished = contains(BooleanField); + + // State + @field possession = contains(StringField); // 'home' | 'away' + @field homeStamina = contains(NumberField); // 0..100 + @field awayStamina = contains(NumberField); // 0..100 + @field score = contains(ScoreField); + @field homeShots = contains(ShotStatsField); + @field awayShots = contains(ShotStatsField); + + // Log + @field matchLog = containsMany(MatchEventField); + + // Computed title from fixture + @field cardTitle = contains(StringField, { + computeVia: function (this: SoccerMatch) { + const h = this.homeTeam?.name ?? 'Home'; + const a = this.awayTeam?.name ?? 'Away'; + return `${h} vs ${a}`; + }, + }); + + // Utility: bounded random + private rand(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + // Simple minute simulation + // Normalize log entries to plain safe objects + private normalizeLog(self: SoccerMatch) { + const log = (self as any).matchLog; + if (!Array.isArray(log)) return; + for (let i = 0; i < log.length; i++) { + const e = log[i]; + if (!e || typeof e !== 'object') { + log.splice(i, 1); + i--; + continue; + } + const minuteNum = Number(e.minute); + const minute = Number.isFinite(minuteNum) + ? Math.max(0, Math.floor(minuteNum)) + : self.currentMinute ?? 0; + const team = + e.team === 'home' || e.team === 'away' || e.team === 'neutral' + ? e.team + : 'neutral'; + const eventType = typeof e.eventType === 'string' ? e.eventType : 'event'; + const note = typeof e.note === 'string' ? e.note : undefined; + log[i] = note + ? { minute, team, eventType, note } + : { minute, team, eventType }; + } + } + + // Normalize momentum series to numbers-only array (filtering invalids) + private normalizeMomentum(self: SoccerMatch) { + let series = (self as any).momentumSeries; + if (!Array.isArray(series)) return; + for (let i = 0; i < series.length; i++) { + const n = Number(series[i]); + if (!Number.isFinite(n)) { + series.splice(i, 1); + i--; + continue; + } + // clamp to safe range [-12, 12] to preserve sparkline scale + const clamped = Math.max(-12, Math.min(12, Math.round(n))); + series[i] = clamped; + } + } + + // store last 12-minute momentum for sparkline + @field momentumSeries = containsMany(NumberField); + + private simulateMinute = (self: SoccerMatch) => { + // Inputs + const hr = self.homeTeam?.rating ?? 75; + const ar = self.awayTeam?.rating ?? 75; + const hs = Math.max(10, Math.min(100, self.homeStamina ?? 100)); + const as = Math.max(10, Math.min(100, self.awayStamina ?? 100)); + const minute = (self.currentMinute ?? 0) + 1; + + // Ensure containers exist before mutation + if (!self.score) self.score = { home: 0, away: 0 } as any; + if (!self.homeShots) self.homeShots = { shots: 0, onTarget: 0 } as any; + if (!self.awayShots) self.awayShots = { shots: 0, onTarget: 0 } as any; + this.ensureLog(self); + + // Possession tilt + const ratingTilt = (hr - ar) * 0.6; + const staminaTilt = ((hs - as) / 5) * 0.4; + const baseTilt = ratingTilt + staminaTilt; // positive → home advantage + const possessionRoll = this.rand(-10, 10) + baseTilt; + const attackingSide = possessionRoll >= 0 ? 'home' : 'away'; + self.possession = attackingSide; + + // Track momentum series for sparkline (bounded to last 12 points) + const momentumPoint = Math.max(-12, Math.min(12, Math.round(baseTilt / 5))); + let series = (self as any).momentumSeries as number[] | undefined; + if (!Array.isArray(series)) { + (self as any).momentumSeries = [momentumPoint] as any; + } else { + series.push(momentumPoint); + if (series.length > 12) series.splice(0, series.length - 12); + } + // Sanitize momentum after update + this.normalizeMomentum(self); + + // Chance of creating an attack in this minute + const attackChance = 0.35 + Math.abs(baseTilt) / 200; // 0.35—0.6 + + const happens = Math.random() < attackChance; + if (happens) { + // Shot probability + const shotChance = 0.65; + if (Math.random() < shotChance) { + const onTargetChance = 0.55; + const isOnTarget = Math.random() < onTargetChance; + + const scoringBias = 0.08 + Math.abs(baseTilt) / 300; // 8%—11% baseline + const goalChance = isOnTarget ? scoringBias : 0.02; + + const teamKey = attackingSide; + const teamName = + teamKey === 'home' + ? self.homeTeam?.name ?? 'Home' + : self.awayTeam?.name ?? 'Away'; + + // Update shots + if (teamKey === 'home') { + self.homeShots.shots = (self.homeShots.shots ?? 0) + 1; + if (isOnTarget) { + self.homeShots.onTarget = (self.homeShots.onTarget ?? 0) + 1; + } + } else { + self.awayShots.shots = (self.awayShots.shots ?? 0) + 1; + if (isOnTarget) { + self.awayShots.onTarget = (self.awayShots.onTarget ?? 0) + 1; + } + } + + // Determine goal/save + if (Math.random() < goalChance) { + // Goal + if (teamKey === 'home') { + self.score.home = (self.score.home ?? 0) + 1; + } else { + self.score.away = (self.score.away ?? 0) + 1; + } + this.addEvent(self, { + minute, + team: teamKey, + eventType: 'goal', + note: `${teamName} score!`, + }); + } else if (isOnTarget) { + // Save + this.addEvent(self, { + minute, + team: teamKey, + eventType: 'save', + note: `${teamName} denied by the keeper`, + }); + } else { + // Shot off target + this.addEvent(self, { + minute, + team: teamKey, + eventType: 'shot', + note: `${teamName} shot off target`, + }); + } + } else { + // Non-shot event: foul or offside or corner + const r = Math.random(); + if (r < 0.5) { + this.addEvent(self, { + minute, + team: attackingSide, + eventType: 'foul', + note: 'Foul given', + }); + if (Math.random() < 0.15) { + this.addEvent(self, { + minute, + team: attackingSide, + eventType: 'yellow', + note: 'Booked', + }); + } + } else if (r < 0.75) { + this.addEvent(self, { + minute, + team: attackingSide, + eventType: 'offside', + note: 'Flag is up', + }); + } else { + this.addEvent(self, { + minute, + team: attackingSide, + eventType: 'corner', + note: 'Corner kick', + }); + } + } + } + + // Apply stamina decay each minute + self.homeStamina = Math.max( + 0, + (self.homeStamina ?? 100) - (happens ? 1.2 : 0.6), + ); + self.awayStamina = Math.max( + 0, + (self.awayStamina ?? 100) - (happens ? 1.2 : 0.6), + ); + + // Time updates + self.currentMinute = minute; + if (!self.isSecondHalf && minute >= 45) { + self.isSecondHalf = true; + // Small halftime recovery + self.homeStamina = Math.min(100, (self.homeStamina ?? 100) + 5); + self.awayStamina = Math.min(100, (self.awayStamina ?? 100) + 5); + } + + // Finish at 90 + addedTime + const nominalEnd = 90 + (self.addedTime ?? 0); + if (minute >= nominalEnd) { + self.isFinished = true; + } + + // Final safety: normalize log after this minute’s updates + this.ensureLog(self); + this.normalizeLog(self); + + // Nothing to append here anymore; events are pushed as they occur via addEvent + }; + + static isolated = class Isolated extends Component { + @tracked fastPlayRunning = false; + + // Build sparkline points for momentumSeries as a space-separated "x,y" list + get pointsAttr() { + try { + const series = (this.args.model as any)?.momentumSeries as + | number[] + | undefined; + if (!Array.isArray(series) || series.length < 2) return ''; + const len = series.length; + const pts: string[] = []; + for (let idx = 0; idx < len; idx++) { + const p = series[idx] ?? 0; + const x = (idx / (len - 1)) * 120; + const y = 24 - p * 0.9; // maps [-12..12] roughly into [~34..~13], then clamped by viewBox + pts.push(`${Math.round(x)},${Math.round(y)}`); + } + return pts.join(' '); + } catch (e) { + // Fallback in case of unexpected data + return ''; + } + } + + get minuteDisplay() { + const m = this.args.model?.currentMinute ?? 0; + const add = this.args.model?.addedTime ?? 0; + const half = this.args.model?.isSecondHalf ? '2H' : '1H'; + return `${m}’ ${half}${add ? ' +' + add : ''}`; + } + + get scoreline() { + const h = this.args.model?.score?.home ?? 0; + const a = this.args.model?.score?.away ?? 0; + return `${h} - ${a}`; + } + + private step = () => { + if ( + !this.args.model || + this.args.model.isPaused || + this.args.model.isFinished + ) + return; + (this.args.model as SoccerMatch as any).simulateMinute(this.args.model); + }; + + private play = restartableTask(async (minutes: number) => { + this.fastPlayRunning = true; + for (let i = 0; i < minutes; i++) { + if (this.args.model?.isPaused || this.args.model?.isFinished) break; + this.step(); + await timeout(300); + } + this.fastPlayRunning = false; + }); + + // Replace perform helper usage with bound methods + playOne = () => { + if (!this.args.model?.isPaused && !this.args.model?.isFinished) { + this.play.perform(1); + } + }; + + playFive = () => { + if (!this.args.model?.isPaused && !this.args.model?.isFinished) { + this.play.perform(5); + } + }; + + start = () => { + if (!this.args.model) return; + this.args.model.isPaused = false; + if ((this.args.model.currentMinute ?? 0) === 0) { + // Kickoff event + this.addEvent(this.args.model as SoccerMatch, { + minute: 0, + team: 'neutral', + eventType: 'kickoff', + note: 'Kickoff!', + }); + } + // Normalize right after possible kickoff insertion + this.ensureLog(this.args.model as SoccerMatch); + this.normalizeLog(this.args.model as SoccerMatch); + }; + + pause = () => { + if (!this.args.model) return; + this.args.model.isPaused = true; + }; + + reset = () => { + const m = this.args.model as SoccerMatch; + if (!m) return; + m.currentMinute = 0; + m.addedTime = 2; + m.isSecondHalf = false; + m.isPaused = true; + m.isFinished = false; + m.possession = 'home'; + m.homeStamina = 100; + m.awayStamina = 100; + + // Ensure score and stats containers exist before mutation + if (!m.score) m.score = { home: 0, away: 0 } as any; + m.score.home = 0; + m.score.away = 0; + + if (!m.homeShots) m.homeShots = { shots: 0, onTarget: 0 } as any; + m.homeShots.shots = 0; + m.homeShots.onTarget = 0; + + if (!m.awayShots) m.awayShots = { shots: 0, onTarget: 0 } as any; + m.awayShots.shots = 0; + m.awayShots.onTarget = 0; + + // Clear log in-place without reassigning the array identity + this.ensureLog(m); + ((m as any).matchLog as any[]).length = 0; + + // Reset momentum series too + (m as any).momentumSeries = [] as any; + + // Normalize for safety + this.normalizeLog(m); + this.normalizeMomentum(m); + }; + + + }; + + static embedded = class Embedded extends Component { + + }; + + static fitted = class Fitted extends Component { + + }; +}