Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions studio/apps/web-react/src/ui/CreatePawnForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -112,7 +121,7 @@ export function CreatePawnForm({ role, controller, presenter }: Props) {
<Field label="Type" required>
<div className="flex gap-1.5">
{types.map((t) => (
<button key={t.id} type="button" onClick={() => setField('type', t.id)} className={toggleClass(form.type === t.id)}>
<button key={t.id} type="button" onClick={() => setType(t.id)} className={toggleClass(form.type === t.id)}>
{t.label}
</button>
))}
Expand Down Expand Up @@ -170,8 +179,7 @@ export function CreatePawnForm({ role, controller, presenter }: Props) {
onChange={(e) => setField('visualKey', e.target.value)} />
</Field>
<Field label="Weapon Key" required>
<input className={inputClass} placeholder="ex : woodcutter_poleaxe" value={form.weaponKey}
onChange={(e) => setField('weaponKey', e.target.value)} />
<WeaponKeySelect type={form.type} value={form.weaponKey} onChange={(weaponKey) => setField('weaponKey', weaponKey)} />
</Field>
</div>
</Section>
Expand Down
24 changes: 2 additions & 22 deletions studio/apps/web-react/src/ui/EditCommanderForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,26 +17,6 @@ const colorMeta: Record<Color, { label: string; dot: string; tint: string }> = {
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;
Expand All @@ -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 = <K extends keyof CommanderFormModel>(field: K, value: CommanderFormModel[K]) =>
Expand Down
15 changes: 12 additions & 3 deletions studio/apps/web-react/src/ui/EditPawnForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -184,7 +194,7 @@ export function EditPawnForm({ role, controller, presenter }: Props) {
{(['melee', 'ranged'] as const).map((t) => {
const active = form.type === t;
return (
<button key={t} type="button" onClick={() => setField('type', t)}
<button key={t} type="button" onClick={() => setType(t)}
className={`rounded-xl border px-3 py-2 text-sm font-semibold transition ${active ? 'border-amber-400/40 bg-amber-400/15 text-amber-200' : 'border-white/[0.07] bg-slate-950/40 text-slate-400 hover:border-white/[0.14] hover:text-slate-200'}`}>
{t === 'melee' ? 'Mêlée' : 'Distance'}
</button>
Expand Down Expand Up @@ -230,8 +240,7 @@ export function EditPawnForm({ role, controller, presenter }: Props) {
onChange={(e) => setField('visualKey', e.target.value)} />
</Field>
<Field label="Weapon Key" required>
<input className={inputClass} value={form.weaponKey}
onChange={(e) => setField('weaponKey', e.target.value)} />
<WeaponKeySelect type={form.type} value={form.weaponKey} onChange={(weaponKey) => setField('weaponKey', weaponKey)} />
</Field>
</div>
</Section>
Expand Down
36 changes: 30 additions & 6 deletions studio/apps/web-react/src/ui/PublishView.tsx
Original file line number Diff line number Diff line change
@@ -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; }
Expand All @@ -9,6 +9,8 @@ export function PublishView() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [status, setStatus] = useState<PublishStatus>('idle');
const [message, setMessage] = useState('');
const [branchName, setBranchName] = useState('');
const submissionStarted = useRef(false);

useEffect(() => {
fetch('/api/catalog/commanders')
Expand All @@ -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 {
Expand All @@ -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.');
}
Expand Down Expand Up @@ -123,9 +129,23 @@ export function PublishView() {
</div>

{status === 'success' && (
<div className="mb-4 flex items-center gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] p-3 text-xs text-emerald-300">
<div className="mb-4 flex items-start gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] p-3 text-xs text-emerald-300">
<Icon className="size-4 shrink-0" name="check" />
<span className="whitespace-pre-line">{message}</span>
<div className="min-w-0 flex-1">
<span className="whitespace-pre-line">{message}</span>
{branchName && (
<div className="mt-2 flex items-center gap-2">
<code className="select-all truncate rounded bg-black/20 px-2 py-1">{branchName}</code>
<button
className="rounded border border-emerald-300/20 px-2 py-1 font-semibold hover:bg-emerald-300/10"
onClick={() => navigator.clipboard.writeText(branchName)}
type="button"
>
Copier
</button>
</div>
)}
</div>
</div>
)}
{status === 'error' && (
Expand All @@ -137,12 +157,16 @@ export function PublishView() {

<button
className="flex w-full items-center justify-center gap-2 rounded-xl bg-amber-400 px-5 py-3 text-sm font-bold text-slate-950 shadow-lg shadow-amber-500/15 transition hover:bg-amber-300 active:translate-y-px disabled:opacity-50"
disabled={selected.size === 0 || status === 'loading'}
disabled={selected.size === 0 || status === 'loading' || status === 'success'}
onClick={publish}
type="button"
>
<Icon className="size-4" name="upload" />
{status === 'loading' ? 'Préparation…' : `Préparer la pull request ${selected.size > 0 ? `(${selected.size})` : ''}`}
{status === 'loading'
? 'Préparation…'
: status === 'success'
? 'Proposition créée'
: `Préparer la pull request ${selected.size > 0 ? `(${selected.size})` : ''}`}
</button>
</>
)}
Expand Down
20 changes: 20 additions & 0 deletions studio/apps/web-react/src/ui/WeaponKeySelect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<select className={inputClass} value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">Sélectionner une arme…</option>
{weaponKeysForType(type).map((weaponKey) => (
<option key={weaponKey} value={weaponKey}>{weaponKey}</option>
))}
</select>
);
}
12 changes: 12 additions & 0 deletions studio/apps/web-react/src/ui/weaponKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import weaponKeysData from '../../../../../data/weaponKeys.json';
import type { PawnType } from '@game-data/presentation';

const weaponKeys: Record<PawnType, readonly string[]> = 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 : '';
}
1 change: 1 addition & 0 deletions studio/src/presentation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
type UpdateCommanderViewModel,
} from './presenters/UpdateCommanderPresenter.ts';
export {
commanderListItemToForm,
createEmptyCommanderForm,
type CommanderFormModel,
} from './models/CommanderFormModel.ts';
Expand Down
23 changes: 23 additions & 0 deletions studio/src/presentation/models/CommanderFormModel.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CommanderListItem } from '@game-data/application';

export interface CommanderFormModel {
readonly id: string;
readonly name: string;
Expand All @@ -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: '',
Expand Down
30 changes: 30 additions & 0 deletions studio/tests/presentation/models/CommanderFormModel.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions studio/tests/ui/WeaponKeySelect.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});