From b0dc6289c64daa75dceec12b48ffef20f18b5dbe Mon Sep 17 00:00:00 2001 From: cpojer Date: Tue, 14 May 2024 12:59:22 -0700 Subject: [PATCH] Allow selecting the AI in the SetupPanel in the editor. GitOrigin-RevId: dcde32caeb6fdf1bcfc52ec7ec26b7b6ad6db658 --- apollo/actions/executeGameAction.tsx | 6 ++--- athena/MapData.tsx | 1 + athena/lib/validateMap.tsx | 3 +-- athena/map/Player.tsx | 37 +++++++++++++++++--------- athena/map/Serialization.tsx | 1 + dionysus/AIRegistry.tsx | 2 +- hera/editor/lib/changePlayer.tsx | 1 + hera/editor/panels/SetupPanel.tsx | 21 +++++++++++++++ hera/ui/AISelector.tsx | 34 +++++++++++++++++++++++ hera/ui/PlayerSelector.tsx | 19 +++++++++++++ tests/__tests__/Building.test.tsx | 17 +++++++++++- tests/__tests__/Fog.test.tsx | 1 + tests/__tests__/FormatActions.test.tsx | 2 +- tests/__tests__/Misses.test.tsx | 13 ++++++++- tests/__tests__/Statistics.test.tsx | 13 ++++++++- 15 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 hera/ui/AISelector.tsx diff --git a/apollo/actions/executeGameAction.tsx b/apollo/actions/executeGameAction.tsx index e05f8d3f..c844677c 100644 --- a/apollo/actions/executeGameAction.tsx +++ b/apollo/actions/executeGameAction.tsx @@ -1,4 +1,3 @@ -import { hasAI } from '@deities/athena/map/Player.tsx'; import MapData from '@deities/athena/MapData.tsx'; import { VisionT } from '@deities/athena/Vision.tsx'; import { Action, execute, MutateActionResponseFn } from '../Action.tsx'; @@ -19,6 +18,7 @@ export type AIRegistryEntry = Readonly<{ new (effects: Effects): AIType; }; name: string; + published: boolean; }>; export type AIRegistryT = ReadonlyMap; @@ -38,9 +38,7 @@ export function executeAIAction( activeMap.getCurrentPlayer().isBot() ) { const player = activeMap.getCurrentPlayer(); - const AIClass = ((hasAI(player) && - player.ai != null && - AIRegistry.get(player.ai)) || + const AIClass = ((player.ai != null && AIRegistry.get(player.ai)) || AIRegistry.get(0))!.class; const ai = new AIClass(effects); diff --git a/athena/MapData.tsx b/athena/MapData.tsx index 786723aa..7289a99f 100644 --- a/athena/MapData.tsx +++ b/athena/MapData.tsx @@ -70,6 +70,7 @@ const nullPlayer = new HumanPlayer( '-1', 0, 0, + undefined, new Set(), new Set(), 0, diff --git a/athena/lib/validateMap.tsx b/athena/lib/validateMap.tsx index f049aa6d..fda430fc 100644 --- a/athena/lib/validateMap.tsx +++ b/athena/lib/validateMap.tsx @@ -26,7 +26,6 @@ import { } from '../map/Configuration.tsx'; import Entity from '../map/Entity.tsx'; import Player, { - hasAI, PlaceholderPlayer, PlayerID, toPlayerID, @@ -391,7 +390,7 @@ export default function validateMap( toPlayerID(id), id, 0, - hasAI(player) && player.ai != null && AIRegistry.has(player.ai) + player.ai != null && AIRegistry.has(player.ai) ? player.ai : undefined, player.skills, diff --git a/athena/map/Player.tsx b/athena/map/Player.tsx index 2735bc61..fd2f58b4 100644 --- a/athena/map/Player.tsx +++ b/athena/map/Player.tsx @@ -26,6 +26,7 @@ export const DynamicPlayerIDs = new Set([ type BasePlainPlayerType = Readonly<{ activeSkills: ReadonlyArray; + ai: number | undefined; charge: number | undefined; funds: number; id: PlayerID; @@ -41,7 +42,6 @@ export type PlainPlayerType = BasePlainPlayerType & type PlainBotType = BasePlainPlayerType & Readonly<{ - ai?: number; name: string; }>; @@ -66,6 +66,7 @@ export default abstract class Player { public readonly id: PlayerID, public readonly teamId: PlayerID, public readonly funds: number, + public readonly ai: number | undefined, public readonly skills: ReadonlySet, public readonly activeSkills: ReadonlySet, public readonly charge: number, @@ -159,6 +160,7 @@ export default abstract class Player { abstract copy(_: { activeSkills?: ReadonlySet; + ai?: number; charge?: number; funds?: number; id?: PlayerID; @@ -177,10 +179,10 @@ export class PlaceholderPlayer extends Player { id: PlayerID, teamId: PlayerID, funds: number, - public readonly ai: number | undefined, + ai: number | undefined, skills: ReadonlySet, ) { - super(id, teamId, funds, skills, new Set(), 0, null, 0); + super(id, teamId, funds, ai, skills, new Set(), 0, null, 0); } copy({ @@ -231,14 +233,14 @@ export class Bot extends Player { public readonly name: string, teamId: PlayerID, funds: number, - public readonly ai: number | undefined, + ai: number | undefined, skills: ReadonlySet, activeSkills: ReadonlySet, charge: number, stats: PlayerStatistics | null, misses: number, ) { - super(id, teamId, funds, skills, activeSkills, charge, stats, misses); + super(id, teamId, funds, ai, skills, activeSkills, charge, stats, misses); } copy({ @@ -320,17 +322,19 @@ export class HumanPlayer extends Player { public readonly userId: string, teamId: PlayerID, funds: number, + ai: number | undefined, skills: ReadonlySet, activeSkills: ReadonlySet, charge: number, stats: PlayerStatistics | null, misses: number, ) { - super(id, teamId, funds, skills, activeSkills, charge, stats, misses); + super(id, teamId, funds, ai, skills, activeSkills, charge, stats, misses); } copy({ activeSkills, + ai, charge, funds, id, @@ -341,6 +345,7 @@ export class HumanPlayer extends Player { userId, }: { activeSkills?: ReadonlySet; + ai?: number; charge?: number; funds?: number; id?: PlayerID; @@ -355,6 +360,7 @@ export class HumanPlayer extends Player { userId ?? this.userId, teamId ?? this.teamId, funds ?? this.funds, + ai ?? this.ai, skills ?? this.skills, activeSkills ?? this.activeSkills, charge ?? this.charge, @@ -364,10 +370,20 @@ export class HumanPlayer extends Player { } toJSON(): PlainPlayerType { - const { activeSkills, charge, funds, id, misses, skills, stats, userId } = - this; + const { + activeSkills, + ai, + charge, + funds, + id, + misses, + skills, + stats, + userId, + } = this; return { activeSkills: [...activeSkills], + ai, charge, funds, id, @@ -386,6 +402,7 @@ export class HumanPlayer extends Player { userId, player.teamId, player.funds, + player.ai, player.skills, player.activeSkills, player.charge, @@ -508,7 +525,3 @@ export function isHumanPlayer(player: Player): player is HumanPlayer { export function isBot(player: Player): player is Bot { return player.isBot(); } - -export function hasAI(player: Player): player is Bot | PlaceholderPlayer { - return isBot(player) || player.isPlaceholder(); -} diff --git a/athena/map/Serialization.tsx b/athena/map/Serialization.tsx index 98a61d0a..15c43c8d 100644 --- a/athena/map/Serialization.tsx +++ b/athena/map/Serialization.tsx @@ -53,6 +53,7 @@ export function decodePlayers( player.userId, teamId, player.funds, + player.ai, skills, activeSkills, player.charge || 0, diff --git a/dionysus/AIRegistry.tsx b/dionysus/AIRegistry.tsx index c02a7248..62c4d5d3 100644 --- a/dionysus/AIRegistry.tsx +++ b/dionysus/AIRegistry.tsx @@ -2,7 +2,7 @@ import { AIRegistryT } from '@deities/apollo/actions/executeGameAction.tsx'; import DionysusAlpha from './DionysusAlpha.tsx'; const AIRegistry: AIRegistryT = new Map([ - [0, { class: DionysusAlpha, name: 'Alpha' }], + [0, { class: DionysusAlpha, name: 'Alpha', published: true }], ]); export default AIRegistry; diff --git a/hera/editor/lib/changePlayer.tsx b/hera/editor/lib/changePlayer.tsx index 692c89b5..0e3dde13 100644 --- a/hera/editor/lib/changePlayer.tsx +++ b/hera/editor/lib/changePlayer.tsx @@ -25,6 +25,7 @@ export default async function changePlayer( userId, existingPlayer?.teamId || id, 0, + undefined, existingPlayer?.skills || new Set(), new Set(), existingPlayer?.charge || 0, diff --git a/hera/editor/panels/SetupPanel.tsx b/hera/editor/panels/SetupPanel.tsx index ff667a64..855c33d7 100644 --- a/hera/editor/panels/SetupPanel.tsx +++ b/hera/editor/panels/SetupPanel.tsx @@ -3,6 +3,7 @@ import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; import updatePlayers from '@deities/athena/lib/updatePlayers.tsx'; import { DefaultMapSkillSlots } from '@deities/athena/map/Configuration.tsx'; import { HumanPlayer, PlayerID } from '@deities/athena/map/Player.tsx'; +import AIRegistry from '@deities/dionysus/AIRegistry.tsx'; import isPresent from '@deities/hephaestus/isPresent.tsx'; import sortBy from '@deities/hephaestus/sortBy.tsx'; import Stack from '@deities/ui/Stack.tsx'; @@ -13,6 +14,11 @@ import PlayerSelector from '../../ui/PlayerSelector.tsx'; import TeamSelector from '../../ui/TeamSelector.tsx'; import { SetMapFunction } from '../Types.tsx'; +const aiRegistry = + process.env.NODE_ENV === 'development' + ? AIRegistry + : new Map([...AIRegistry].filter(([, { published }]) => published)); + export default function MapEditorSetupPanel({ setMap, state, @@ -47,6 +53,19 @@ export default function MapEditorSetupPanel({ [mapWithPlayers], ); + const onSelectAI = useCallback( + (playerID: PlayerID, ai: number) => { + const player = map.getPlayer(playerID); + setMap( + 'teams', + map.copy({ + teams: updatePlayer(map.teams, player.copy({ ai })), + }), + ); + }, + [map, setMap], + ); + const onSelectSkills = useCallback( (playerID: PlayerID, slot: number, skill: Skill | null) => { const player = map.getPlayer(playerID); @@ -80,10 +99,12 @@ export default function MapEditorSetupPanel({ placeholders /> 1 ? aiRegistry : null} availableSkills={Skills} hasSkills map={mapWithPlayers} onSelect={null} + onSelectAI={onSelectAI} onSelectSkills={onSelectSkills} skillSlots={DefaultMapSkillSlots} users={placeholderUsers} diff --git a/hera/ui/AISelector.tsx b/hera/ui/AISelector.tsx new file mode 100644 index 00000000..d8f6d8ce --- /dev/null +++ b/hera/ui/AISelector.tsx @@ -0,0 +1,34 @@ +import { AIRegistryT } from '@deities/apollo/actions/executeGameAction.tsx'; +import InlineLink from '@deities/ui/InlineLink.tsx'; +import Select from '@deities/ui/Select.tsx'; +import Stack from '@deities/ui/Stack.tsx'; + +export default function AISelector({ + currentAI, + registry, + setAI, +}: { + currentAI: number | undefined; + registry: AIRegistryT; + setAI: (id: number) => void; +}) { + const currentEntry = ( + currentAI != null ? registry.get(currentAI) : registry.get(0) + )!; + return ( + + AI: + + + ); +} diff --git a/hera/ui/PlayerSelector.tsx b/hera/ui/PlayerSelector.tsx index abcb77e6..a54b846d 100644 --- a/hera/ui/PlayerSelector.tsx +++ b/hera/ui/PlayerSelector.tsx @@ -1,3 +1,4 @@ +import { AIRegistryT } from '@deities/apollo/actions/executeGameAction.tsx'; import { Skill } from '@deities/athena/info/Skill.tsx'; import Player, { isBot, PlayerID } from '@deities/athena/map/Player.tsx'; import MapData from '@deities/athena/MapData.tsx'; @@ -14,6 +15,7 @@ import Android from '@iconify-icons/pixelarticons/android.js'; import { ReactNode, useCallback, useState } from 'react'; import MiniPortrait from '../character/MiniPortrait.tsx'; import { CharacterImage } from '../character/PortraitPicker.tsx'; +import AISelector from './AISelector.tsx'; import PlayerIcon from './PlayerIcon.tsx'; import PlayerPosition from './PlayerPosition.tsx'; import { SkillIcon, SkillSelector } from './SkillDialog.tsx'; @@ -31,6 +33,7 @@ type UserLike = export default function PlayerSelector({ activePlayer, + aiRegistry, availableSkills, children, hasSkills, @@ -39,6 +42,7 @@ export default function PlayerSelector({ map, onClick, onSelect, + onSelectAI, onSelectSkill, onSelectSkills, selectedPlayer, @@ -47,6 +51,7 @@ export default function PlayerSelector({ viewerPlayerID, }: { activePlayer?: PlayerID | null; + aiRegistry?: AIRegistryT | null; availableSkills?: ReadonlySet | null; children?: ReactNode; hasSkills: boolean; @@ -55,6 +60,7 @@ export default function PlayerSelector({ map: MapData; onClick?: ((player: PlayerID) => void) | null; onSelect?: ((player: PlayerID) => void) | null; + onSelectAI?: ((player: PlayerID, ai: number) => void) | null; onSelectSkill?: ((slot: number, skill: Skill | null) => void) | null; onSelectSkills?: | ((player: PlayerID, slot: number, skill: Skill | null) => void) @@ -78,6 +84,7 @@ export default function PlayerSelector({ .filter(({ id }) => id > 0) .map((player: Player) => ( onClick(player.id) : undefined} onSelect={onSelect ? () => onSelect(player.id) : undefined} + onSelectAI={onSelectAI} onSelectSkill={onSelectSkill} onSelectSkills={onSelectSkills} player={player} @@ -222,6 +230,7 @@ const PlayerSkillSelectors = ({ }; const PlayerItem = ({ + aiRegistry, availableSkills, blocklistedSkills, hasSkills, @@ -231,6 +240,7 @@ const PlayerItem = ({ locked, onClick, onSelect, + onSelectAI, onSelectSkill, onSelectSkills, player, @@ -238,6 +248,7 @@ const PlayerItem = ({ user, viewerPlayerID, }: { + aiRegistry?: AIRegistryT | null; availableSkills?: ReadonlySet | null; blocklistedSkills?: ReadonlySet; hasSkills: boolean; @@ -247,6 +258,7 @@ const PlayerItem = ({ locked: boolean; onClick?: () => void; onSelect?: () => void; + onSelectAI?: ((player: PlayerID, ai: number) => void) | null; onSelectSkill?: ((slot: number, skill: Skill | null) => void) | null; onSelectSkills?: | ((player: PlayerID, slot: number, skill: Skill | null) => void) @@ -317,6 +329,13 @@ const PlayerItem = ({ ) ) : null} + {aiRegistry && ( + onSelectAI?.(player.id, id)} + /> + )} ); } diff --git a/tests/__tests__/Building.test.tsx b/tests/__tests__/Building.test.tsx index b8e9ba01..b9457889 100644 --- a/tests/__tests__/Building.test.tsx +++ b/tests/__tests__/Building.test.tsx @@ -23,7 +23,18 @@ test('ensures the configuration for buildings and the units they can create is c ...building .create(1) .getBuildableUnits( - new HumanPlayer(1, '1', 1, 0, new Set(), new Set(), 0, null, 0), + new HumanPlayer( + 1, + '1', + 1, + 0, + undefined, + new Set(), + new Set(), + 0, + null, + 0, + ), ), ] .map((unit) => unit.name) @@ -94,6 +105,7 @@ test('units can be added to buildings via skills', () => { '1', 1, 0, + undefined, new Set(), new Set(), 0, @@ -105,6 +117,7 @@ test('units can be added to buildings via skills', () => { '1', 1, 0, + undefined, new Set([Skill.BuyUnitCannon]), new Set(), 0, @@ -135,6 +148,7 @@ test('units can be added to the Bar via skills', () => { '1', 1, 0, + undefined, new Set(), new Set(), 0, @@ -146,6 +160,7 @@ test('units can be added to the Bar via skills', () => { '1', 1, 0, + undefined, new Set([Skill.BuyUnitBazookaBear]), new Set(), 0, diff --git a/tests/__tests__/Fog.test.tsx b/tests/__tests__/Fog.test.tsx index 7c4b5eb2..08901a2b 100644 --- a/tests/__tests__/Fog.test.tsx +++ b/tests/__tests__/Fog.test.tsx @@ -103,6 +103,7 @@ test('capturing an opponent HQ will reveal nearby units and buildings', async () 'User-6', 3, 500, + undefined, new Set(), new Set(), 0, diff --git a/tests/__tests__/FormatActions.test.tsx b/tests/__tests__/FormatActions.test.tsx index 8f4a5040..561935da 100644 --- a/tests/__tests__/FormatActions.test.tsx +++ b/tests/__tests__/FormatActions.test.tsx @@ -242,6 +242,6 @@ test('format spawn actions', () => { { colors: false }, ), ).toMatchInlineSnapshot( - `"Spawn { units: [2,1 → Pioneer { id: 1, health: 100, player: 1, fuel: 40 }, 1,2 → Pioneer { id: 1, health: 100, player: 1, fuel: 40 }, 5,4 → Pioneer { id: 1, health: 100, player: 2, fuel: 40 }, 4,5 → Pioneer { id: 1, health: 100, player: 2, fuel: 40 }], teams: [ { id: 1, name: '', players: [ { activeSkills: [], charge: 0, funds: 10000, id: 1, misses: 0, skills: [], stats: [ 0, 0, 0, 0, 0, 0, 0, 0 ], userId: '1' } ] }, { id: 2, name: '', players: [ { activeSkills: [], charge: 0, funds: 10000, id: 2, misses: 0, skills: [], stats: [ 0, 0, 0, 0, 0, 0, 0, 0 ], userId: '2' } ] } ] }"`, + `"Spawn { units: [2,1 → Pioneer { id: 1, health: 100, player: 1, fuel: 40 }, 1,2 → Pioneer { id: 1, health: 100, player: 1, fuel: 40 }, 5,4 → Pioneer { id: 1, health: 100, player: 2, fuel: 40 }, 4,5 → Pioneer { id: 1, health: 100, player: 2, fuel: 40 }], teams: [ { id: 1, name: '', players: [ { activeSkills: [], ai: undefined, charge: 0, funds: 10000, id: 1, misses: 0, skills: [], stats: [ 0, 0, 0, 0, 0, 0, 0, 0 ], userId: '1' } ] }, { id: 2, name: '', players: [ { activeSkills: [], ai: undefined, charge: 0, funds: 10000, id: 2, misses: 0, skills: [], stats: [ 0, 0, 0, 0, 0, 0, 0, 0 ], userId: '2' } ] } ] }"`, ); }); diff --git a/tests/__tests__/Misses.test.tsx b/tests/__tests__/Misses.test.tsx index 90faff32..fc88db35 100644 --- a/tests/__tests__/Misses.test.tsx +++ b/tests/__tests__/Misses.test.tsx @@ -110,7 +110,18 @@ test('lose the game, but continue when missing two turns in a row with multiple ImmutableMap([ [ 3, - new HumanPlayer(3, '3', 3, 300, new Set(), new Set(), 0, null, 0), + new HumanPlayer( + 3, + '3', + 3, + 300, + undefined, + new Set(), + new Set(), + 0, + null, + 0, + ), ], ]), ), diff --git a/tests/__tests__/Statistics.test.tsx b/tests/__tests__/Statistics.test.tsx index 450ec0a7..74b9166c 100644 --- a/tests/__tests__/Statistics.test.tsx +++ b/tests/__tests__/Statistics.test.tsx @@ -148,7 +148,18 @@ test('collects statistics when attacking buildings', async () => { ImmutableMap([ [ 3, - new HumanPlayer(3, '3', 3, 300, new Set(), new Set(), 0, null, 0), + new HumanPlayer( + 3, + '3', + 3, + 300, + undefined, + new Set(), + new Set(), + 0, + null, + 0, + ), ], ]), ),