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
26 changes: 26 additions & 0 deletions .github/workflows/open-studio-pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Open Studio pull request

on:
push:
branches:
- studio/publish-*

permissions:
contents: read
pull-requests: write

jobs:
open-pull-request:
runs-on: ubuntu-latest
steps:
- name: Create pull request
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
HEAD_BRANCH: ${{ github.ref_name }}
run: |
gh pr create \
--base main \
--head "$HEAD_BRANCH" \
--title "feat(data): update production game data" \
--body "Proposition générée et validée depuis Game Data Studio."
17 changes: 11 additions & 6 deletions .github/workflows/release-on-main.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Create patch version
name: Tag merged version

on:
push:
Expand All @@ -8,12 +8,11 @@ permissions:
contents: write

concurrency:
group: release-main
group: tag-main
cancel-in-progress: false

jobs:
release:
if: ${{ !contains(github.event.head_commit.message, 'chore(release)') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -51,9 +50,15 @@ jobs:
- name: Build legacy editor
run: npm test

- name: Create patch commit and tag
- name: Tag package version
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
npm version patch -m "chore(release): %s [skip ci]"
git push origin HEAD:main --follow-tags
VERSION="$(node -p "require('./package.json').version")"
TAG="v$VERSION"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "$TAG existe déjà, aucun nouveau tag nécessaire."
exit 0
fi
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
22 changes: 12 additions & 10 deletions data/commanders.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"turnCount": 2,
"power": 8,
"nonePower": 2,
"visualKey": "Aegis_soldier_melee_red",
"visualKey": "Aegis_soldier_ranged_red",
"weaponKey": "short_iron_katana"
},
{
Expand Down Expand Up @@ -99,19 +99,21 @@
}
},
{
"id": "Aegis_officer_3",
"displayName": "Archer vert",
"color": "green",
"id": "Aegis_officer_4",
"displayName": "savant rouge",
"color": "red",
"type": "ranged",
"turnCount": 3,
"power": 24,
"countPawns": 2,
"power": 12,
"countPawns": 1,
"moveCount": 2,
"visualKey": "Aegis_officer_ranged_green",
"weaponKey": "arrow",
"requiredInfluencePoints": 8,
"visualKey": "Aegis_officer_ranged_red",
"weaponKey": "shuriken",
"requiredInfluencePoints": 10,
"implicitSkillParams": {
"spBonusPerLiaison": 1
"columnPowerBonusPerDecrement": 6,
"freeWallDestructsOnDecrement": 1,
"liaisonBonusPercent": 10
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion studio/apps/web-react/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const commandersModule: ModuleDef<CommanderView> = {
views: [
{ id: 'create', label: 'Créer', icon: 'spark' },
{ id: 'edit', label: 'Modifier', icon: 'identity' },
{ id: 'publish', label: 'Publier', icon: 'upload' },
{ id: 'publish', label: 'Proposer en production', icon: 'upload' },
],
};

Expand Down
13 changes: 8 additions & 5 deletions studio/apps/web-react/src/ui/PublishView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function PublishView() {
body: JSON.stringify({ commanderIds: Array.from(selected) }),
});
const body = await response.json() as {
published?: { id: string; name: string }[];
prepared?: { id: string; name: string }[];
branchName?: string;
error?: string;
errors?: string[];
};
Expand All @@ -49,7 +50,9 @@ export function PublishView() {
setMessage(body.errors?.join('\n') ?? body.error ?? `Erreur ${response.status}`);
} else {
setStatus('success');
setMessage(`${(body.published ?? []).length} commander(s) publié(s) avec succès.`);
setMessage(
`Proposition créée sur ${body.branchName ?? 'la branche distante'} pour ${(body.prepared ?? []).length} commander(s). La pull request va être ouverte automatiquement.`,
);
}
} catch {
setStatus('error');
Expand All @@ -65,8 +68,8 @@ export function PublishView() {
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.22em] text-amber-400/70">Export</p>
<h2 className="mt-0.5 text-lg font-semibold tracking-tight text-white">Publier des commandants</h2>
<p className="mt-1 text-sm leading-6 text-slate-400">Sélectionnez les commandants à exporter vers <code className="rounded bg-white/[0.05] px-1 py-0.5 text-xs text-amber-300">data/commanders.json</code>.</p>
<h2 className="mt-0.5 text-lg font-semibold tracking-tight text-white">Préparer une pull request</h2>
<p className="mt-1 text-sm leading-6 text-slate-400">Sélectionnez les commandants à proposer dans les données de production.</p>
</div>
</div>

Expand Down Expand Up @@ -139,7 +142,7 @@ export function PublishView() {
type="button"
>
<Icon className="size-4" name="upload" />
{status === 'loading' ? 'Publication…' : `Publier ${selected.size > 0 ? `(${selected.size})` : ''}`}
{status === 'loading' ? 'Préparation…' : `Préparer la pull request ${selected.size > 0 ? `(${selected.size})` : ''}`}
</button>
</>
)}
Expand Down
10 changes: 9 additions & 1 deletion studio/apps/web-react/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';
import {
CreateCommander,
CreateProductionGameDataProposal,
GenerateProductionGameData,
ListCommanders,
ProductionGameDataValidator,
Expand All @@ -18,6 +19,7 @@ import {
JsonSkillCatalogRepository,
JsonWallVisualSetCatalogRepository,
JsonWeaponKeyCatalogRepository,
GitProductionGameDataProposalGateway,
} from '@game-data/infrastructure';
import { CommanderCatalogApiHandler } from './vite/CommanderCatalogApiHandler.ts';
import { PawnCatalogApiHandler } from './vite/PawnCatalogApiHandler.ts';
Expand Down Expand Up @@ -68,7 +70,13 @@ const generateProductionGameData = new GenerateProductionGameData(
new JsonWallVisualSetCatalogRepository(fileURLToPath(new URL('../../../data/wallVisualSets.json', import.meta.url))),
new ProductionGameDataValidator(),
);
const publishHandler = new PublishApiHandler(generateProductionGameData, productionCommanderCatalogPath);
const repositoryPath = fileURLToPath(new URL('../../../', import.meta.url));
const publishHandler = new PublishApiHandler(
new CreateProductionGameDataProposal(
generateProductionGameData,
new GitProductionGameDataProposalGateway(repositoryPath),
),
);

export default defineConfig({
plugins: [react(), tailwindcss(), commanderCatalogApiPlugin(handler, pawnHandler, pawnApiHandler, wallVisualSetHandler, publishHandler)],
Expand Down
42 changes: 8 additions & 34 deletions studio/apps/web-react/vite/PublishApiHandler.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,24 @@
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { dirname } from 'node:path';
import {
ProductionGameDataValidationError,
type GenerateProductionGameData,
type CreateProductionGameDataProposal,
type CreateProductionGameDataProposalResult,
} from '@game-data/application';

export interface PublishResult {
published: { id: string; name: string }[];
}

interface PublishError {
readonly error: string;
readonly errors?: readonly string[];
}

export class PublishApiHandler {
private readonly generateProductionGameData: Pick<GenerateProductionGameData, 'execute'>;
private readonly productionCatalogPath: string;
private readonly createProposal: Pick<CreateProductionGameDataProposal, 'execute'>;

public constructor(
generateProductionGameData: Pick<GenerateProductionGameData, 'execute'>,
productionCatalogPath: string,
) {
this.generateProductionGameData = generateProductionGameData;
this.productionCatalogPath = productionCatalogPath;
public constructor(createProposal: Pick<CreateProductionGameDataProposal, 'execute'>) {
this.createProposal = createProposal;
}

public async handle(input: unknown): Promise<{
status: number;
body: PublishResult | PublishError;
body: CreateProductionGameDataProposalResult | PublishError;
}> {
const commanderIds = this.parseIds(input);
if (!commanderIds) {
Expand All @@ -40,12 +29,9 @@ export class PublishApiHandler {
}

try {
const catalog = await this.generateProductionGameData.execute({ commanderIds });
await this.write(catalog);
const selected = catalog.filter(({ id }) => commanderIds.includes(id));
return {
status: 200,
body: { published: selected.map(({ id, name }) => ({ id, name })) },
body: await this.createProposal.execute({ commanderIds }),
};
} catch (error) {
if (error instanceof ProductionGameDataValidationError) {
Expand All @@ -60,7 +46,7 @@ export class PublishApiHandler {
if (error instanceof Error && /not found/.test(error.message)) {
return { status: 404, body: { error: error.message } };
}
return { status: 500, body: { error: 'La publication a échoué.' } };
return { status: 500, body: { error: 'La création de la proposition a échoué.' } };
}
}

Expand All @@ -71,16 +57,4 @@ export class PublishApiHandler {
if (commanderIds.some((id) => typeof id !== 'string' || id.trim().length === 0)) return null;
return commanderIds as string[];
}

private async write(catalog: unknown): Promise<void> {
const temporaryPath = `${this.productionCatalogPath}.${randomUUID()}.tmp`;
await mkdir(dirname(this.productionCatalogPath), { recursive: true });
try {
await writeFile(temporaryPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8');
await rename(temporaryPath, this.productionCatalogPath);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
}
}
6 changes: 6 additions & 0 deletions studio/src/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export type { SkillCatalogRepository } from './ports/SkillCatalogRepository.ts';
export type { WeaponKeyCatalogRepository } from './ports/WeaponKeyCatalogRepository.ts';
export type { WallVisualSetCatalogRepository } from './ports/WallVisualSetCatalogRepository.ts';
export type { ProductionCommanderCatalogRepository } from './ports/ProductionCommanderCatalogRepository.ts';
export type {
ProductionGameDataProposal,
ProductionGameDataProposalGateway,
} from './ports/ProductionGameDataProposalGateway.ts';
export type {
ProductionCommanderDocument,
ProductionPawnDocument,
Expand Down Expand Up @@ -44,6 +48,8 @@ export type {
GenerateProductionGameDataRequest,
} from './use-cases/GenerateProductionGameData.ts';
export { GenerateProductionGameData } from './use-cases/GenerateProductionGameData.ts';
export type { CreateProductionGameDataProposalResult } from './use-cases/CreateProductionGameDataProposal.ts';
export { CreateProductionGameDataProposal } from './use-cases/CreateProductionGameDataProposal.ts';
export {
ProductionGameDataValidationError,
ProductionGameDataValidator,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ProductionCommanderDocument } from '../models/ProductionCommanderDocument.ts';

export interface ProductionGameDataProposal {
readonly branchName: string;
}

export interface ProductionGameDataProposalGateway {
create(commanders: readonly ProductionCommanderDocument[]): Promise<ProductionGameDataProposal>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ProductionGameDataProposalGateway } from '../ports/ProductionGameDataProposalGateway.ts';
import type {
GenerateProductionGameData,
GenerateProductionGameDataRequest,
} from './GenerateProductionGameData.ts';

export interface CreateProductionGameDataProposalResult {
readonly branchName: string;
readonly prepared: readonly { readonly id: string; readonly name: string }[];
}

export class CreateProductionGameDataProposal {
private readonly generateProductionGameData: Pick<GenerateProductionGameData, 'execute'>;
private readonly proposalGateway: ProductionGameDataProposalGateway;

public constructor(
generateProductionGameData: Pick<GenerateProductionGameData, 'execute'>,
proposalGateway: ProductionGameDataProposalGateway,
) {
this.generateProductionGameData = generateProductionGameData;
this.proposalGateway = proposalGateway;
}

public async execute(
request: GenerateProductionGameDataRequest,
): Promise<CreateProductionGameDataProposalResult> {
const commanders = await this.generateProductionGameData.execute(request);
const proposal = await this.proposalGateway.create(commanders);
const requestedIds = new Set(request.commanderIds);
return {
branchName: proposal.branchName,
prepared: commanders
.filter(({ id }) => requestedIds.has(id))
.map(({ id, name }) => ({ id, name })),
};
}
}
Loading
Loading