refactor: extract shared FightData interface to fight/types.ts

Move FightData, FightRound, FightBot, FightArenaInfo types from local
definitions in FightViewer.vue to shared fight/types.ts. Replace `any`
typing in HumanFightPage, FightPage, useFightPolling, and useFightCache
with proper typed interfaces. Fix null-safety guards exposed by typing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 06:19:26 +00:00
co-authored by Claude Opus 4.6
parent 2f7c10e4b8
commit 945a776a5a
6 changed files with 81 additions and 48 deletions
+2 -29
View File
@@ -14,34 +14,7 @@ import {
} from '../game/audio' } from '../game/audio'
import { isKokoroLoading, getKokoroProgress } from '../game/tts' import { isKokoroLoading, getKokoroProgress } from '../game/tts'
import { isPerfMode, setPerfMode } from '../game/fight/config' import { isPerfMode, setPerfMode } from '../game/fight/config'
import type { FightData, FightRound } from '../game/fight/types'
interface Round {
roundNumber: number
challengeType: string
challengeData: string
botAResponse: string | null
botATimeMs: number | null
botAScore: number | null
botBResponse: string | null
botBTimeMs: number | null
botBScore: number | null
winnerId: string | null
narration: string | null
}
interface FightData {
id: string
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
arena: string
winnerId: string | null
botAHp: number
botBHp: number
totalRounds: number
status: string
rounds: Round[]
}
const props = defineProps<{ fight: FightData; autoplay?: boolean }>() const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
const emit = defineEmits<{ 'replay-done': [] }>() const emit = defineEmits<{ 'replay-done': [] }>()
@@ -297,7 +270,7 @@ async function showHitText(text: string, color: string, x: number) {
hitTextVisible.value = false hitTextVisible.value = false
} }
function addRoundToLog(round: Round, stagger: boolean): Promise<void> { function addRoundToLog(round: FightRound, stagger: boolean): Promise<void> {
if (!stagger) { if (!stagger) {
const challenge = JSON.parse(round.challengeData) const challenge = JSON.parse(round.challengeData)
logItems.value.push( logItems.value.push(
+4 -2
View File
@@ -1,3 +1,5 @@
import type { FightData } from '../game/fight/types'
const DB_NAME = 'botfights_cache' const DB_NAME = 'botfights_cache'
const STORE_NAME = 'fights' const STORE_NAME = 'fights'
const MAX_CACHED = 5 const MAX_CACHED = 5
@@ -17,7 +19,7 @@ function openDB(): Promise<IDBDatabase> {
} }
/** Cache a fight replay for offline viewing */ /** Cache a fight replay for offline viewing */
export async function cacheFight(fightData: { id: string; [key: string]: any }): Promise<void> { export async function cacheFight(fightData: FightData): Promise<void> {
try { try {
const db = await openDB() const db = await openDB()
const tx = db.transaction(STORE_NAME, 'readwrite') const tx = db.transaction(STORE_NAME, 'readwrite')
@@ -50,7 +52,7 @@ export async function cacheFight(fightData: { id: string; [key: string]: any }):
} }
/** Get a cached fight by ID */ /** Get a cached fight by ID */
export async function getCachedFight(id: string): Promise<any | null> { export async function getCachedFight(id: string): Promise<FightData | null> {
try { try {
const db = await openDB() const db = await openDB()
return new Promise((resolve) => { return new Promise((resolve) => {
+7 -5
View File
@@ -1,4 +1,5 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
import type { FightData, FightBot, FightArenaInfo } from '../game/fight/types'
export interface LiveLogItem { export interface LiveLogItem {
type: string type: string
@@ -8,11 +9,12 @@ export interface LiveLogItem {
} }
export interface LiveFightData { export interface LiveFightData {
botA: any botA: FightBot | null
botB: any botB: FightBot | null
arena: string arena: string
arenaInfo?: { name: string } arenaInfo?: FightArenaInfo | null
[key: string]: any mode?: string
potSats?: number
} }
interface SSEListenerEntry { interface SSEListenerEntry {
@@ -25,7 +27,7 @@ export function useFightPolling(fightId: Ref<string>) {
const isLoading = ref(true) const isLoading = ref(true)
const liveRounds = ref(0) const liveRounds = ref(0)
const fightError = ref('') const fightError = ref('')
const fight = ref<any>(null) const fight = ref<FightData | null>(null)
const liveFightData = ref<LiveFightData | null>(null) const liveFightData = ref<LiveFightData | null>(null)
const spectatorCount = ref(0) const spectatorCount = ref(0)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null) const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
+55
View File
@@ -2,6 +2,8 @@ import type kaplay from 'kaplay'
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp } from 'kaplay' import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp } from 'kaplay'
import type { SpriteCustomization } from '../sprites' import type { SpriteCustomization } from '../sprites'
export type { SpriteCustomization }
export type Fighter = GameObj<SpriteComp | PosComp | ScaleComp | AnchorComp | OpacityComp | ColorComp | RotateComp | ZComp> export type Fighter = GameObj<SpriteComp | PosComp | ScaleComp | AnchorComp | OpacityComp | ColorComp | RotateComp | ZComp>
export type KaplayInstance = ReturnType<typeof kaplay> export type KaplayInstance = ReturnType<typeof kaplay>
@@ -85,3 +87,56 @@ export interface RoundEvent {
botAResponse?: string botAResponse?: string
botBResponse?: string botBResponse?: string
} }
// --- Shared fight data types (used across pages & composables) ---
export interface FightRound {
roundNumber: number
challengeType: string
challengeData: string
botAResponse: string | null
botATimeMs: number | null
botAScore: number | null
botBResponse: string | null
botBTimeMs: number | null
botBScore: number | null
winnerId: string | null
narration: string | null
}
export interface FightBot {
id: string
name: string
avatarSeed: string
archetype?: string
customization?: SpriteCustomization | null
profilePicUrl?: string | null
eloRating: number
wins: number
losses: number
tier: number
botType?: string
}
export interface FightArenaInfo {
id: string
name: string
description: string
modifier: string | null
}
export interface FightData {
id: string
botAId: string
botBId: string
botA: FightBot | null
botB: FightBot | null
arenaInfo: FightArenaInfo | null
arena: string
winnerId: string | null
botAHp: number
botBHp: number
totalRounds: number
status: string
rounds: FightRound[]
}
+10 -10
View File
@@ -139,8 +139,8 @@ async function initLiveScene() {
try { try {
liveScene = await createFightScene({ liveScene = await createFightScene({
canvas: liveCanvas.value, canvas: liveCanvas.value,
botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: data.botA.archetype, customization: data.botA.customization, wins: data.botA.wins, losses: data.botA.losses }, botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: data.botA.archetype, customization: data.botA.customization ?? undefined, wins: data.botA.wins, losses: data.botA.losses },
botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: data.botB.archetype, customization: data.botB.customization, wins: data.botB.wins, losses: data.botB.losses }, botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: data.botB.archetype, customization: data.botB.customization ?? undefined, wins: data.botB.wins, losses: data.botB.losses },
arena: data.arena, arena: data.arena,
}) })
} catch (err) { } catch (err) {
@@ -219,7 +219,7 @@ async function showLiveOverlay(text: string, color: string, duration: number) {
async function handleRoundEnd(data: any) { async function handleRoundEnd(data: any) {
const fd = liveFightData.value const fd = liveFightData.value
if (!fd) { if (!fd || !fd.botA || !fd.botB) {
pendingSSEEvents.value.push({ type: 'round_end', data }) pendingSSEEvents.value.push({ type: 'round_end', data })
return return
} }
@@ -297,7 +297,7 @@ async function handleRoundEnd(data: any) {
async function handleFightEnd(data: any) { async function handleFightEnd(data: any) {
const fd = liveFightData.value const fd = liveFightData.value
if (!fd) { if (!fd || !fd.botA || !fd.botB) {
pendingSSEEvents.value.push({ type: 'fight_end', data }) pendingSSEEvents.value.push({ type: 'fight_end', data })
return return
} }
@@ -746,7 +746,7 @@ function stopAutoBattle() {
<div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" /> <div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div> </div>
<span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span> <span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB.name }}</p> <p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB?.name }}</p>
</div> </div>
</div> </div>
<!-- Desktop: single-row layout --> <!-- Desktop: single-row layout -->
@@ -762,7 +762,7 @@ function stopAutoBattle() {
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30"> <div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" /> <div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div> </div>
<p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB.name }}</p> <p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB?.name }}</p>
</div> </div>
<div class="flex items-center justify-between mt-0.5"> <div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span> <span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
@@ -771,7 +771,7 @@ function stopAutoBattle() {
<span v-if="liveFightData.mode === 'ranked'" class="text-neon-cyan"> | {{ liveFightData.potSats || 42 }} SATS</span> <span v-if="liveFightData.mode === 'ranked'" class="text-neon-cyan"> | {{ liveFightData.potSats || 42 }} SATS</span>
<span v-if="spectatorCount > 0" class="text-neon-cyan"> | {{ spectatorCount }} watching</span> <span v-if="spectatorCount > 0" class="text-neon-cyan"> | {{ spectatorCount }} watching</span>
</span> </span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span> <span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB?.tier || 0)">{{ Math.round(liveFightData.botB?.eloRating || 0) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -900,7 +900,7 @@ function stopAutoBattle() {
<div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" /> <div class="h-full bg-neon-pink transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div> </div>
<span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span> <span class="font-mono font-bold text-[10px] w-5 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB.name }}</p> <p class="font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB?.name }}</p>
</div> </div>
</div> </div>
<!-- Desktop: single-row layout --> <!-- Desktop: single-row layout -->
@@ -916,7 +916,7 @@ function stopAutoBattle() {
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30"> <div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" /> <div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div> </div>
<p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB.name }}</p> <p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB?.name }}</p>
</div> </div>
<div class="flex items-center justify-between mt-0.5"> <div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span> <span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
@@ -924,7 +924,7 @@ function stopAutoBattle() {
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }} {{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
<span v-if="spectatorCount > 0" class="text-neon-cyan ml-1">| {{ spectatorCount }} watching</span> <span v-if="spectatorCount > 0" class="text-neon-cyan ml-1">| {{ spectatorCount }} watching</span>
</span> </span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span> <span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB?.tier || 0)">{{ Math.round(liveFightData.botB?.eloRating || 0) }}</span>
</div> </div>
</div> </div>
</div> </div>
+3 -2
View File
@@ -3,13 +3,14 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr' import { useNostr } from '../composables/useNostr'
import FightViewer from '../components/FightViewer.vue' import FightViewer from '../components/FightViewer.vue'
import type { FightData } from '../game/fight/types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { bot: myBot, isLoggedIn } = useNostr() const { bot: myBot, isLoggedIn } = useNostr()
const fightId = ref(route.params.fightId as string) const fightId = ref(route.params.fightId as string)
const fight = ref<any>(null) const fight = ref<FightData | null>(null)
const phase = ref<'waiting' | 'challenge' | 'submitted' | 'between' | 'finished' | 'replay' | 'error'>('waiting') const phase = ref<'waiting' | 'challenge' | 'submitted' | 'between' | 'finished' | 'replay' | 'error'>('waiting')
const error = ref('') const error = ref('')
@@ -198,7 +199,7 @@ async function loadFight() {
opponentName.value = enemy?.name || 'Unknown' opponentName.value = enemy?.name || 'Unknown'
} }
if (data.rounds) { if (data.rounds) {
roundResults.value = data.rounds.map((r: any) => { roundResults.value = data.rounds.map((r: { roundNumber: number; winnerId: string | null }) => {
return { round: r.roundNumber, won: r.winnerId === myBotId.value, hpA: 0, hpB: 0 } return { round: r.roundNumber, won: r.winnerId === myBotId.value, hpA: 0, hpB: 0 }
}) })
myHp.value = amSideA.value ? data.botAHp : data.botBHp myHp.value = amSideA.value ? data.botAHp : data.botBHp