feat: add conflict dialog for duplicate characters/weapons
This commit is contained in:
parent
ff7199fbbb
commit
8b078cdfd8
5 changed files with 459 additions and 8 deletions
3
src/assets/icons/loader-2.svg
Normal file
3
src/assets/icons/loader-2.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 229 B |
|
|
@ -10,8 +10,10 @@
|
||||||
import { BaseAdapter } from './base.adapter'
|
import { BaseAdapter } from './base.adapter'
|
||||||
import type { AdapterOptions } from './types'
|
import type { AdapterOptions } from './types'
|
||||||
import type { GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
|
import type { GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
|
||||||
|
import type { Character, Weapon } from '$lib/types/api/entities'
|
||||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||||
import { validateGridWeapon, validateGridCharacter, validateGridSummon } from '$lib/utils/gridValidation'
|
import { validateGridWeapon, validateGridCharacter, validateGridSummon } from '$lib/utils/gridValidation'
|
||||||
|
import { isConflictResponse } from '$lib/types/api/conflict'
|
||||||
|
|
||||||
// GridWeapon, GridCharacter, and GridSummon types are imported from types/api/party
|
// GridWeapon, GridCharacter, and GridSummon types are imported from types/api/party
|
||||||
// Re-export for test files and consumers
|
// Re-export for test files and consumers
|
||||||
|
|
@ -88,6 +90,24 @@ export interface ResolveConflictParams {
|
||||||
conflictingIds: string[]
|
conflictingIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character conflict response from API
|
||||||
|
*/
|
||||||
|
export interface CharacterConflictResponse {
|
||||||
|
position: number
|
||||||
|
conflicts: GridCharacter[]
|
||||||
|
incoming: Character
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weapon conflict response from API
|
||||||
|
*/
|
||||||
|
export interface WeaponConflictResponse {
|
||||||
|
position: number
|
||||||
|
conflicts: GridWeapon[]
|
||||||
|
incoming: Weapon
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grid adapter for managing user's grid item instances
|
* Grid adapter for managing user's grid item instances
|
||||||
*/
|
*/
|
||||||
|
|
@ -97,16 +117,23 @@ export class GridAdapter extends BaseAdapter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new grid weapon instance
|
* Creates a new grid weapon instance
|
||||||
|
* Returns either a GridWeapon on success, or a WeaponConflictResponse if conflicts are detected
|
||||||
*/
|
*/
|
||||||
async createWeapon(params: CreateGridWeaponParams, headers?: Record<string, string>): Promise<GridWeapon> {
|
async createWeapon(params: CreateGridWeaponParams, headers?: Record<string, string>): Promise<GridWeapon | WeaponConflictResponse> {
|
||||||
const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons', {
|
const response = await this.request<{ gridWeapon: GridWeapon } | WeaponConflictResponse>('/grid_weapons', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { weapon: params },
|
body: { weapon: params },
|
||||||
headers
|
headers
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate and normalize response
|
// Check if this is a conflict response
|
||||||
const validated = validateGridWeapon(response.gridWeapon)
|
if (isConflictResponse(response)) {
|
||||||
|
return response as WeaponConflictResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal success response - validate and normalize
|
||||||
|
const gridWeaponResponse = response as { gridWeapon: GridWeapon }
|
||||||
|
const validated = validateGridWeapon(gridWeaponResponse.gridWeapon)
|
||||||
if (!validated) {
|
if (!validated) {
|
||||||
throw new Error('API returned incomplete GridWeapon data')
|
throw new Error('API returned incomplete GridWeapon data')
|
||||||
}
|
}
|
||||||
|
|
@ -208,16 +235,23 @@ export class GridAdapter extends BaseAdapter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new grid character instance
|
* Creates a new grid character instance
|
||||||
|
* Returns either a GridCharacter on success, or a CharacterConflictResponse if conflicts are detected
|
||||||
*/
|
*/
|
||||||
async createCharacter(params: CreateGridCharacterParams, headers?: Record<string, string>): Promise<GridCharacter> {
|
async createCharacter(params: CreateGridCharacterParams, headers?: Record<string, string>): Promise<GridCharacter | CharacterConflictResponse> {
|
||||||
const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters', {
|
const response = await this.request<{ gridCharacter: GridCharacter } | CharacterConflictResponse>('/grid_characters', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { character: params },
|
body: { character: params },
|
||||||
headers
|
headers
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate and normalize response
|
// Check if this is a conflict response
|
||||||
const validated = validateGridCharacter(response.gridCharacter)
|
if (isConflictResponse(response)) {
|
||||||
|
return response as CharacterConflictResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal success response - validate and normalize
|
||||||
|
const gridCharacterResponse = response as { gridCharacter: GridCharacter }
|
||||||
|
const validated = validateGridCharacter(gridCharacterResponse.gridCharacter)
|
||||||
if (!validated) {
|
if (!validated) {
|
||||||
throw new Error('API returned incomplete GridCharacter data')
|
throw new Error('API returned incomplete GridCharacter data')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
src/lib/components/dialogs/ConflictDialog.module.scss
Normal file
83
src/lib/components/dialogs/ConflictDialog.module.scss
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* ConflictDialog Styles
|
||||||
|
*
|
||||||
|
* Layout: [Conflicting Items] → [Incoming Item]
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
$item-diameter: 10rem;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: $font-regular;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
:global(strong) {
|
||||||
|
font-weight: $bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflicts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
text-align: center;
|
||||||
|
width: $item-diameter;
|
||||||
|
font-weight: $medium;
|
||||||
|
font-size: $font-small;
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: $unit;
|
||||||
|
width: $item-diameter;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
height: $item-diameter;
|
||||||
|
padding: 0 $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoming {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
239
src/lib/components/dialogs/ConflictDialog.svelte
Normal file
239
src/lib/components/dialogs/ConflictDialog.svelte
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
<!--
|
||||||
|
ConflictDialog Component
|
||||||
|
|
||||||
|
A unified dialog for handling character and weapon conflicts when adding
|
||||||
|
units to a party that would violate uniqueness constraints.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
```svelte
|
||||||
|
<ConflictDialog
|
||||||
|
bind:open={conflictDialogOpen}
|
||||||
|
conflict={conflictData}
|
||||||
|
partyId={party.id}
|
||||||
|
partyShortcode={party.shortcode}
|
||||||
|
onResolve={() => { conflictData = null }}
|
||||||
|
onCancel={() => { conflictData = null }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import type { ConflictData } from '$lib/types/api/conflict'
|
||||||
|
import type { GridCharacter, GridWeapon } from '$lib/types/api/party'
|
||||||
|
import type { Character, Weapon } from '$lib/types/api/entities'
|
||||||
|
import { useResolveCharacterConflict, useResolveWeaponConflict } from '$lib/api/mutations/grid.mutations'
|
||||||
|
import { getCharacterImageWithPose, getWeaponImage } from '$lib/utils/images'
|
||||||
|
import { getWeaponSeriesSlug, isOpusDraconicSeries } from '$lib/utils/weaponSeries'
|
||||||
|
import { getLocale } from '$lib/paraglide/runtime.js'
|
||||||
|
import * as m from '$lib/paraglide/messages'
|
||||||
|
|
||||||
|
import styles from './ConflictDialog.module.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Controls dialog visibility */
|
||||||
|
open: boolean
|
||||||
|
/** Conflict data from API response */
|
||||||
|
conflict: ConflictData | null
|
||||||
|
/** Party ID for resolve request */
|
||||||
|
partyId: string
|
||||||
|
/** Party shortcode for cache invalidation */
|
||||||
|
partyShortcode: string
|
||||||
|
/** Callback when conflict is resolved */
|
||||||
|
onResolve?: () => void
|
||||||
|
/** Callback when dialog is cancelled */
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
conflict,
|
||||||
|
partyId,
|
||||||
|
partyShortcode,
|
||||||
|
onResolve,
|
||||||
|
onCancel
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Mutations for resolving conflicts
|
||||||
|
const resolveCharacterMutation = useResolveCharacterConflict()
|
||||||
|
const resolveWeaponMutation = useResolveWeaponConflict()
|
||||||
|
|
||||||
|
// Get current locale for name display
|
||||||
|
const locale = $derived(getLocale() as 'en' | 'ja')
|
||||||
|
|
||||||
|
// Loading state - use mutation.current in Svelte 5
|
||||||
|
let isLoading = $state(false)
|
||||||
|
|
||||||
|
// Generate conflict message based on type
|
||||||
|
const conflictMessage = $derived.by(() => {
|
||||||
|
if (!conflict) return ''
|
||||||
|
|
||||||
|
if (conflict.type === 'character') {
|
||||||
|
return m.conflict_character_message()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon conflict - check if it's Opus/Draconic
|
||||||
|
const weapon = conflict.incoming
|
||||||
|
if (isOpusDraconicSeries(weapon.series)) {
|
||||||
|
return m.conflict_weapon_opus_draconic()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get series name for message
|
||||||
|
const seriesSlug = getWeaponSeriesSlug(weapon.series)
|
||||||
|
|
||||||
|
// Use the series slug directly for now - proper i18n can be added later
|
||||||
|
const seriesName = seriesSlug?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Unknown'
|
||||||
|
|
||||||
|
return m.conflict_weapon_series({ series: seriesName })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle confirm button click - resolve the conflict
|
||||||
|
*/
|
||||||
|
async function handleResolve() {
|
||||||
|
if (!conflict) return
|
||||||
|
|
||||||
|
const resolveParams = {
|
||||||
|
partyId,
|
||||||
|
partyShortcode,
|
||||||
|
incomingId: conflict.incoming.id,
|
||||||
|
position: conflict.position,
|
||||||
|
conflictingIds: conflict.conflicts.map((c) => c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
try {
|
||||||
|
if (conflict.type === 'character') {
|
||||||
|
await resolveCharacterMutation.mutateAsync(resolveParams)
|
||||||
|
} else {
|
||||||
|
await resolveWeaponMutation.mutateAsync(resolveParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false
|
||||||
|
onResolve?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ConflictDialog] Failed to resolve conflict:', error)
|
||||||
|
// Error will be handled by mutation's error state
|
||||||
|
} finally {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel button click
|
||||||
|
*/
|
||||||
|
function handleCancel() {
|
||||||
|
open = false
|
||||||
|
onCancel?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dialog open change (e.g., clicking overlay or X button)
|
||||||
|
*/
|
||||||
|
function handleOpenChange(newOpen: boolean) {
|
||||||
|
if (!newOpen) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get character image URL with proper pose
|
||||||
|
*/
|
||||||
|
function getCharacterUrl(gridChar: GridCharacter): string {
|
||||||
|
return getCharacterImageWithPose(
|
||||||
|
gridChar.character.granblueId,
|
||||||
|
'square',
|
||||||
|
gridChar.uncapLevel,
|
||||||
|
gridChar.transcendenceStep
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incoming character image URL (default pose)
|
||||||
|
*/
|
||||||
|
function getIncomingCharacterUrl(character: Character): string {
|
||||||
|
return getCharacterImageWithPose(character.granblueId, 'square', 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get weapon image URL
|
||||||
|
*/
|
||||||
|
function getWeaponUrl(gridWeapon: GridWeapon): string {
|
||||||
|
return getWeaponImage(gridWeapon.weapon.granblueId, 'square')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incoming weapon image URL
|
||||||
|
*/
|
||||||
|
function getIncomingWeaponUrl(weapon: Weapon): string {
|
||||||
|
return getWeaponImage(weapon.granblueId, 'square')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange} title={m.conflict_title()}>
|
||||||
|
{#if conflict}
|
||||||
|
<div class={styles.content}>
|
||||||
|
<p class={styles.message}>{conflictMessage}</p>
|
||||||
|
|
||||||
|
<div class={styles.diagram}>
|
||||||
|
<!-- Conflicting items (left side) -->
|
||||||
|
<ul class={styles.conflicts}>
|
||||||
|
{#if conflict.type === 'character'}
|
||||||
|
{#each conflict.conflicts as gridChar (gridChar.id)}
|
||||||
|
<li class={styles.item}>
|
||||||
|
<img
|
||||||
|
src={getCharacterUrl(gridChar)}
|
||||||
|
alt={gridChar.character.name[locale]}
|
||||||
|
/>
|
||||||
|
<span>{gridChar.character.name[locale]}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each conflict.conflicts as gridWeapon (gridWeapon.id)}
|
||||||
|
<li class={styles.item}>
|
||||||
|
<img
|
||||||
|
src={getWeaponUrl(gridWeapon)}
|
||||||
|
alt={gridWeapon.weapon.name[locale]}
|
||||||
|
/>
|
||||||
|
<span>{gridWeapon.weapon.name[locale]}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Arrow -->
|
||||||
|
<span class={styles.arrow}>→</span>
|
||||||
|
|
||||||
|
<!-- Incoming item (right side) -->
|
||||||
|
<div class={styles.incoming}>
|
||||||
|
{#if conflict.type === 'character'}
|
||||||
|
<div class={styles.item}>
|
||||||
|
<img
|
||||||
|
src={getIncomingCharacterUrl(conflict.incoming)}
|
||||||
|
alt={conflict.incoming.name[locale]}
|
||||||
|
/>
|
||||||
|
<span>{conflict.incoming.name[locale]}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class={styles.item}>
|
||||||
|
<img
|
||||||
|
src={getIncomingWeaponUrl(conflict.incoming)}
|
||||||
|
alt={conflict.incoming.name[locale]}
|
||||||
|
/>
|
||||||
|
<span>{conflict.incoming.name[locale]}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}>
|
||||||
|
{m.conflict_cancel()}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onclick={handleResolve} disabled={isLoading}>
|
||||||
|
{m.conflict_confirm()}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog>
|
||||||
92
src/lib/types/api/conflict.ts
Normal file
92
src/lib/types/api/conflict.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
/**
|
||||||
|
* Conflict Types
|
||||||
|
*
|
||||||
|
* Type definitions for conflict detection and resolution when adding
|
||||||
|
* characters or weapons to a party that would violate uniqueness constraints.
|
||||||
|
*
|
||||||
|
* @module types/api/conflict
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Character, Weapon } from './entities'
|
||||||
|
import type { GridCharacter, GridWeapon } from './party'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of units that can have conflicts
|
||||||
|
*/
|
||||||
|
export type ConflictType = 'character' | 'weapon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conflict data when adding a character that already exists in the party
|
||||||
|
*/
|
||||||
|
export interface CharacterConflictData {
|
||||||
|
type: 'character'
|
||||||
|
position: number
|
||||||
|
conflicts: GridCharacter[]
|
||||||
|
incoming: Character
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conflict data when adding a weapon that violates series constraints
|
||||||
|
*/
|
||||||
|
export interface WeaponConflictData {
|
||||||
|
type: 'weapon'
|
||||||
|
position: number
|
||||||
|
conflicts: GridWeapon[]
|
||||||
|
incoming: Weapon
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for all conflict data
|
||||||
|
*/
|
||||||
|
export type ConflictData = CharacterConflictData | WeaponConflictData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw conflict response from API (before adding type discriminator)
|
||||||
|
*/
|
||||||
|
export interface RawConflictResponse {
|
||||||
|
position: number
|
||||||
|
conflicts: unknown[]
|
||||||
|
incoming: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an API response is a conflict response.
|
||||||
|
* Conflict responses have `conflicts`, `incoming`, and `position` properties.
|
||||||
|
*
|
||||||
|
* @param data - The API response data to check
|
||||||
|
* @returns True if the response is a conflict response
|
||||||
|
*/
|
||||||
|
export function isConflictResponse(data: unknown): data is RawConflictResponse {
|
||||||
|
return (
|
||||||
|
data !== null &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
'conflicts' in data &&
|
||||||
|
'incoming' in data &&
|
||||||
|
'position' in data &&
|
||||||
|
Array.isArray((data as RawConflictResponse).conflicts)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates typed ConflictData from a raw API conflict response.
|
||||||
|
*
|
||||||
|
* @param raw - The raw conflict response from the API
|
||||||
|
* @param type - The type of conflict ('character' or 'weapon')
|
||||||
|
* @returns Typed ConflictData ready for use in ConflictDialog
|
||||||
|
*/
|
||||||
|
export function createConflictData(raw: RawConflictResponse, type: ConflictType): ConflictData {
|
||||||
|
if (type === 'character') {
|
||||||
|
return {
|
||||||
|
type: 'character',
|
||||||
|
position: raw.position,
|
||||||
|
conflicts: raw.conflicts as GridCharacter[],
|
||||||
|
incoming: raw.incoming as Character
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'weapon',
|
||||||
|
position: raw.position,
|
||||||
|
conflicts: raw.conflicts as GridWeapon[],
|
||||||
|
incoming: raw.incoming as Weapon
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue