diff --git a/studio/apps/web-react/src/ui/CreatePawnForm.tsx b/studio/apps/web-react/src/ui/CreatePawnForm.tsx index 321b4a2..ef4a59b 100644 --- a/studio/apps/web-react/src/ui/CreatePawnForm.tsx +++ b/studio/apps/web-react/src/ui/CreatePawnForm.tsx @@ -10,6 +10,8 @@ import { } from '@game-data/presentation'; import skillsData from '../../../../../data/skills.json'; import { Icon, Field, inputClass, Section, SkillPicker } from './ui-kit'; +import { WeaponKeySelect } from './WeaponKeySelect'; +import { weaponKeyAfterTypeChange } from './weaponKeys'; const pawnSkills = skillsData.pawnSkillVisuals as { id: string; displayName: string }[]; @@ -59,6 +61,13 @@ export function CreatePawnForm({ role, controller, presenter }: Props) { const setImplicit = (key: ImplicitKey, value: string) => setForm((prev) => ({ ...prev, implicitSkillParams: { ...prev.implicitSkillParams, [key]: value } })); + const setType = (type: PawnType) => + setForm((prev) => ({ + ...prev, + type, + weaponKey: weaponKeyAfterTypeChange(prev.weaponKey, type), + })); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); await controller.submit(form); @@ -112,7 +121,7 @@ export function CreatePawnForm({ role, controller, presenter }: Props) {
{types.map((t) => ( - ))} @@ -170,8 +179,7 @@ export function CreatePawnForm({ role, controller, presenter }: Props) { onChange={(e) => setField('visualKey', e.target.value)} /> - setField('weaponKey', e.target.value)} /> + setField('weaponKey', weaponKey)} />
diff --git a/studio/apps/web-react/src/ui/EditCommanderForm.tsx b/studio/apps/web-react/src/ui/EditCommanderForm.tsx index 58989bb..94e4b6a 100644 --- a/studio/apps/web-react/src/ui/EditCommanderForm.tsx +++ b/studio/apps/web-react/src/ui/EditCommanderForm.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useSyncExternalStore, type FormEvent } from 'react'; -import { type CommanderFormModel, type UpdateCommanderController, type UpdateCommanderPresenter } from '@game-data/presentation'; +import { commanderListItemToForm, type CommanderFormModel, type UpdateCommanderController, type UpdateCommanderPresenter } from '@game-data/presentation'; import type { CommanderListItem } from '@game-data/application'; import skillsData from '../../../../../data/skills.json'; import { Icon, Field, inputClass, Section, SkillPicker } from './ui-kit'; @@ -17,26 +17,6 @@ const colorMeta: Record = { green: { label: 'Vert', dot: 'bg-emerald-400', tint: 'from-emerald-500/10' }, }; -function itemToForm(item: CommanderListItem): CommanderFormModel { - return { - id: item.id, - name: item.name, - description: item.description ?? '', - icon: item.icon ?? '', - pawnMax: item.pawnMax, - health: item.health, - maxDefenseLevel: item.maxDefenseLevel, - wallVisualSet: item.wallVisualSet, - defensePowerPerLevel: item.defensePowerPerLevel, - pawnDefinitionIdByColor: item.pawnDefinitionIdByColor, - commanderPawnDefinitionIds: item.commanderPawnDefinitionIds, - officerPawnDefinitionIds: item.officerPawnDefinitionIds, - movementsPerTurn: item.movementsPerTurn, - skills: item.skills ?? [], - innateSkills: item.innateSkills ?? [], - }; -} - interface Props { readonly controller: UpdateCommanderController; readonly presenter: UpdateCommanderPresenter; @@ -59,7 +39,7 @@ export function EditCommanderForm({ controller, presenter }: Props) { const selectCommander = (id: string) => { setSelectedId(id); const item = commanders.find((c) => c.id === id); - setForm(item ? itemToForm(item) : null); + setForm(item ? commanderListItemToForm(item) : null); }; const setField = (field: K, value: CommanderFormModel[K]) => diff --git a/studio/apps/web-react/src/ui/EditPawnForm.tsx b/studio/apps/web-react/src/ui/EditPawnForm.tsx index 0999bc7..c20298a 100644 --- a/studio/apps/web-react/src/ui/EditPawnForm.tsx +++ b/studio/apps/web-react/src/ui/EditPawnForm.tsx @@ -5,9 +5,12 @@ import { type UpdatePawnDefinitionController, type UpdatePawnDefinitionPresenter, type PawnRole, + type PawnType, } from '@game-data/presentation'; import skillsData from '../../../../../data/skills.json'; import { Icon, Field, inputClass, Section, SkillPicker } from './ui-kit'; +import { WeaponKeySelect } from './WeaponKeySelect'; +import { weaponKeyAfterTypeChange } from './weaponKeys'; const pawnSkills = skillsData.pawnSkillVisuals as { id: string; displayName: string }[]; @@ -109,6 +112,13 @@ export function EditPawnForm({ role, controller, presenter }: Props) { const setImplicit = (key: ImplicitKey, value: string) => setForm((prev) => prev ? { ...prev, implicitSkillParams: { ...prev.implicitSkillParams, [key]: value } } : null); + const setType = (type: PawnType) => + setForm((prev) => prev ? { + ...prev, + type, + weaponKey: weaponKeyAfterTypeChange(prev.weaponKey, type), + } : null); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!form) return; @@ -184,7 +194,7 @@ export function EditPawnForm({ role, controller, presenter }: Props) { {(['melee', 'ranged'] as const).map((t) => { const active = form.type === t; return ( - @@ -230,8 +240,7 @@ export function EditPawnForm({ role, controller, presenter }: Props) { onChange={(e) => setField('visualKey', e.target.value)} />
- setField('weaponKey', e.target.value)} /> + setField('weaponKey', weaponKey)} /> diff --git a/studio/apps/web-react/src/ui/PublishView.tsx b/studio/apps/web-react/src/ui/PublishView.tsx index d2721ba..d73e082 100644 --- a/studio/apps/web-react/src/ui/PublishView.tsx +++ b/studio/apps/web-react/src/ui/PublishView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Icon } from './ui-kit'; interface CommanderItem { id: string; name: string; } @@ -9,6 +9,8 @@ export function PublishView() { const [selected, setSelected] = useState>(new Set()); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); + const [branchName, setBranchName] = useState(''); + const submissionStarted = useRef(false); useEffect(() => { fetch('/api/catalog/commanders') @@ -30,7 +32,8 @@ export function PublishView() { }; const publish = async () => { - if (selected.size === 0) return; + if (selected.size === 0 || submissionStarted.current) return; + submissionStarted.current = true; setStatus('loading'); setMessage(''); try { @@ -46,15 +49,18 @@ export function PublishView() { errors?: string[]; }; if (!response.ok) { + submissionStarted.current = false; setStatus('error'); setMessage(body.errors?.join('\n') ?? body.error ?? `Erreur ${response.status}`); } else { setStatus('success'); + setBranchName(body.branchName ?? ''); setMessage( `Proposition créée sur ${body.branchName ?? 'la branche distante'} pour ${(body.prepared ?? []).length} commander(s). La pull request va être ouverte automatiquement.`, ); } } catch { + submissionStarted.current = false; setStatus('error'); setMessage('Erreur réseau.'); } @@ -123,9 +129,23 @@ export function PublishView() { {status === 'success' && ( -
+
- {message} +
+ {message} + {branchName && ( +
+ {branchName} + +
+ )} +
)} {status === 'error' && ( @@ -137,12 +157,16 @@ export function PublishView() { )} diff --git a/studio/apps/web-react/src/ui/WeaponKeySelect.tsx b/studio/apps/web-react/src/ui/WeaponKeySelect.tsx new file mode 100644 index 0000000..2cb652c --- /dev/null +++ b/studio/apps/web-react/src/ui/WeaponKeySelect.tsx @@ -0,0 +1,20 @@ +import type { PawnType } from '@game-data/presentation'; +import { inputClass } from './ui-kit'; +import { weaponKeysForType } from './weaponKeys'; + +interface Props { + readonly type: PawnType; + readonly value: string; + readonly onChange: (weaponKey: string) => void; +} + +export function WeaponKeySelect({ type, value, onChange }: Props) { + return ( + + ); +} diff --git a/studio/apps/web-react/src/ui/weaponKeys.ts b/studio/apps/web-react/src/ui/weaponKeys.ts new file mode 100644 index 0000000..6a97fb1 --- /dev/null +++ b/studio/apps/web-react/src/ui/weaponKeys.ts @@ -0,0 +1,12 @@ +import weaponKeysData from '../../../../../data/weaponKeys.json'; +import type { PawnType } from '@game-data/presentation'; + +const weaponKeys: Record = weaponKeysData; + +export function weaponKeysForType(type: PawnType): readonly string[] { + return weaponKeys[type]; +} + +export function weaponKeyAfterTypeChange(currentWeaponKey: string, type: PawnType): string { + return weaponKeysForType(type).includes(currentWeaponKey) ? currentWeaponKey : ''; +} diff --git a/studio/src/presentation/index.ts b/studio/src/presentation/index.ts index eafb439..d8c8aae 100644 --- a/studio/src/presentation/index.ts +++ b/studio/src/presentation/index.ts @@ -15,6 +15,7 @@ export { type UpdateCommanderViewModel, } from './presenters/UpdateCommanderPresenter.ts'; export { + commanderListItemToForm, createEmptyCommanderForm, type CommanderFormModel, } from './models/CommanderFormModel.ts'; diff --git a/studio/src/presentation/models/CommanderFormModel.ts b/studio/src/presentation/models/CommanderFormModel.ts index 84f1c16..d32702b 100644 --- a/studio/src/presentation/models/CommanderFormModel.ts +++ b/studio/src/presentation/models/CommanderFormModel.ts @@ -1,3 +1,5 @@ +import type { CommanderListItem } from '@game-data/application'; + export interface CommanderFormModel { readonly id: string; readonly name: string; @@ -21,6 +23,27 @@ export interface CommanderFormModel { readonly innateSkills?: readonly string[]; } +export function commanderListItemToForm(item: CommanderListItem): CommanderFormModel { + return { + id: item.id, + name: item.name, + description: item.description ?? '', + icon: item.icon ?? '', + pawnMax: item.pawnMax, + health: item.health, + maxDefenseLevel: item.maxDefenseLevel, + wallVisualSet: item.wallVisualSet, + defensePowerPerLevel: item.defensePowerPerLevel, + pawnDefinitionIdByColor: item.pawnDefinitionIdByColor, + commanderPawnDefinitionIds: item.commanderPawnDefinitionIds, + officerPawnDefinitionIds: item.officerPawnDefinitionIds, + movementsPerTurn: item.movementsPerTurn, + freeRecruitThreshold: item.freeRecruitThreshold, + skills: item.skills ?? [], + innateSkills: item.innateSkills ?? [], + }; +} + export function createEmptyCommanderForm(): CommanderFormModel { return { id: '', diff --git a/studio/tests/presentation/models/CommanderFormModel.test.ts b/studio/tests/presentation/models/CommanderFormModel.test.ts new file mode 100644 index 0000000..9b3fd15 --- /dev/null +++ b/studio/tests/presentation/models/CommanderFormModel.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { CommanderListItem } from '../../../src/application/use-cases/ListCommanders'; +import { commanderListItemToForm } from '../../../src/presentation/models/CommanderFormModel'; + +describe('CommanderFormModel', () => { + it('préremplit le seuil de recrutement gratuit du commander sélectionné', () => { + const commander: CommanderListItem = { + id: 'commander-1', + name: 'Commander', + pawnMax: 40, + health: 100, + maxDefenseLevel: 2, + wallVisualSet: 'default', + defensePowerPerLevel: 6, + pawnDefinitionIdByColor: { + red: 'pawn-red', + blue: 'pawn-blue', + green: 'pawn-green', + }, + commanderPawnDefinitionIds: [], + officerPawnDefinitionIds: [], + movementsPerTurn: 3, + freeRecruitThreshold: 20, + }; + + const form = commanderListItemToForm(commander); + + expect(form.freeRecruitThreshold).toBe(20); + }); +}); diff --git a/studio/tests/ui/WeaponKeySelect.test.ts b/studio/tests/ui/WeaponKeySelect.test.ts new file mode 100644 index 0000000..ec700f5 --- /dev/null +++ b/studio/tests/ui/WeaponKeySelect.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + weaponKeyAfterTypeChange, + weaponKeysForType, +} from '../../apps/web-react/src/ui/weaponKeys'; + +describe('WeaponKeySelect', () => { + it('propose uniquement les armes de mêlée pour un pion melee', () => { + expect(weaponKeysForType('melee')).toEqual([ + 'sword', + 'spear', + 'wooden_spiked_club', + 'woodcutter_poleaxe', + 'short_iron_katana', + ]); + }); + + it('propose uniquement les projectiles pour un pion ranged', () => { + expect(weaponKeysForType('ranged')).toEqual(['arrow', 'shuriken']); + }); + + it('retire une arme devenue incompatible après un changement de type', () => { + expect(weaponKeyAfterTypeChange('sword', 'ranged')).toBe(''); + expect(weaponKeyAfterTypeChange('arrow', 'ranged')).toBe('arrow'); + }); +});