diff --git a/client/src/components/game/tournament-renderer/tournament-game.tsx b/client/src/components/game/tournament-renderer/tournament-game.tsx
index dad20b5f..7942214c 100644
--- a/client/src/components/game/tournament-renderer/tournament-game.tsx
+++ b/client/src/components/game/tournament-renderer/tournament-game.tsx
@@ -31,12 +31,12 @@ export const TournamentGameElement: React.FC
= ({ lines, game }) => {
return
}
const loadedGame = Game.loadFullGameRaw(buffer)
- appContext.setState({
- ...appContext.state,
+ appContext.setState((prevState) => ({
+ ...prevState,
activeGame: loadedGame,
activeMatch: loadedGame.currentMatch,
queue: appContext.state.queue.concat([loadedGame])
- })
+ }))
game.viewed = true
setPage(PageType.GAME)
setLoadingGame(false)
diff --git a/client/src/components/input-dialog.tsx b/client/src/components/input-dialog.tsx
index a771d315..6eca213e 100644
--- a/client/src/components/input-dialog.tsx
+++ b/client/src/components/input-dialog.tsx
@@ -17,7 +17,7 @@ export const InputDialog: React.FC> = (props) => {
const context = useAppContext()
React.useEffect(() => {
- context.setState({ ...context.state, disableHotkeys: props.open })
+ context.setState((prevState) => ({ ...prevState, disableHotkeys: props.open }))
if (props.open) {
setValue(props.defaultValue ?? '')
}
diff --git a/client/src/components/sidebar/map-editor/MapGenerator.ts b/client/src/components/sidebar/map-editor/MapGenerator.ts
index 9c50d853..010e73c6 100644
--- a/client/src/components/sidebar/map-editor/MapGenerator.ts
+++ b/client/src/components/sidebar/map-editor/MapGenerator.ts
@@ -60,7 +60,6 @@ function verifyMapGuarantees(turn: Turn) {
const loc = turn.map.staticMap.spawnLocations[i]
for (let x = loc.x - 1; x <= loc.x + 1; x++) {
for (let y = loc.y - 1; y <= loc.y + 1; y++) {
- if (x == loc.x && y == loc.y) continue
if (x < 0 || x >= turn.map.width || y < 0 || y >= turn.map.height) continue
const mapIdx = turn.map.locationToIndex(x, y)
if (
@@ -73,8 +72,8 @@ function verifyMapGuarantees(turn: Turn) {
}
}
}
- if (totalSpawnableLocations < 18) {
- return `Map has ${totalSpawnableLocations} spawnable locations. Must have at least 9 for each team`
+ if (totalSpawnableLocations < 9 * 3 * 2) {
+ return `Map has ${totalSpawnableLocations} spawnable locations. Must have 9 * 3 for each team`
}
const floodMask = new Int8Array(turn.map.width * turn.map.height)
@@ -92,6 +91,13 @@ function verifyMapGuarantees(turn: Turn) {
if (x < 0 || x >= turn.map.width || y < 0 || y >= turn.map.height) continue
const newIdx = turn.map.locationToIndex(x, y)
if (!turn.map.staticMap.divider[newIdx] && !floodMask[newIdx]) {
+ // Check if we can reach an enemy spawn location
+ for (let i = 0; i < turn.map.staticMap.spawnLocations.length; i++) {
+ const loc = turn.map.staticMap.spawnLocations[i]
+ if (loc.x == x && loc.y == y && i % 2 != 0)
+ return `Maps cannot have spawn zones that are initially reachable by both teams`
+ }
+
floodMask[newIdx] = 1
floodQueue.push(newIdx)
totalFlooded++
diff --git a/client/src/components/sidebar/map-editor/map-editor.tsx b/client/src/components/sidebar/map-editor/map-editor.tsx
index 24c2f4bd..b2ae822c 100644
--- a/client/src/components/sidebar/map-editor/map-editor.tsx
+++ b/client/src/components/sidebar/map-editor/map-editor.tsx
@@ -103,11 +103,11 @@ export const MapEditorPage: React.FC = (props) => {
// multiple times
mapParams.imported = undefined
- context.setState({
- ...context.state,
- activeGame: editGame.current,
- activeMatch: editGame.current.currentMatch
- })
+ context.setState((prevState) => ({
+ ...prevState,
+ activeGame: editGame.current ?? undefined,
+ activeMatch: editGame.current?.currentMatch
+ }))
const turn = editGame.current.currentMatch!.currentTurn
const brushes = turn.map.getEditorBrushes().concat(turn.bodies.getEditorBrushes(turn.map.staticMap))
@@ -115,11 +115,11 @@ export const MapEditorPage: React.FC = (props) => {
setBrushes(brushes)
setCleared(turn.bodies.isEmpty() && turn.map.isEmpty())
} else {
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
activeGame: undefined,
activeMatch: undefined
- })
+ }))
}
}, [mapParams, props.open])
diff --git a/client/src/components/sidebar/queue/queue-game.tsx b/client/src/components/sidebar/queue/queue-game.tsx
index 44220953..62889bb6 100644
--- a/client/src/components/sidebar/queue/queue-game.tsx
+++ b/client/src/components/sidebar/queue/queue-game.tsx
@@ -18,20 +18,20 @@ export const QueuedGame: React.FC = (props) => {
const setMatch = (match: Match) => {
match.jumpToTurn(0)
props.game.currentMatch = match
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
activeGame: match.game,
activeMatch: match
- })
+ }))
}
const close = () => {
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
queue: context.state.queue.filter((v) => v !== props.game),
activeGame: context.state.activeGame === props.game ? undefined : context.state.activeGame,
activeMatch: context.state.activeGame === props.game ? undefined : context.state.activeMatch
- })
+ }))
}
const getWinText = (winType: schema.WinType) => {
@@ -74,8 +74,16 @@ export const QueuedGame: React.FC = (props) => {
{!isTournamentMode && (
-
- {match.winner.name}
- {` wins ${getWinText(match.winType)}after ${match.maxTurn} rounds`}
+ {match.winner !== null && match.winType !== null ? (
+ <>
+
+ {match.winner.name}
+
+ {` wins ${getWinText(match.winType)}after ${match.maxTurn} rounds`}
+ >
+ ) : (
+ Winner not known
+ )}
)}
diff --git a/client/src/components/sidebar/queue/queue.tsx b/client/src/components/sidebar/queue/queue.tsx
index 972bf513..d6bc714e 100644
--- a/client/src/components/sidebar/queue/queue.tsx
+++ b/client/src/components/sidebar/queue/queue.tsx
@@ -29,12 +29,12 @@ export const QueuePage: React.FC = (props) => {
const selectedMatch = game.matches[0]
game.currentMatch = selectedMatch
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
queue: queue.concat([game]),
activeGame: game,
activeMatch: selectedMatch
- })
+ }))
}
reader.readAsArrayBuffer(file)
}
diff --git a/client/src/components/sidebar/runner/scaffold.ts b/client/src/components/sidebar/runner/scaffold.ts
index 59bf94bc..bc7f1f4b 100644
--- a/client/src/components/sidebar/runner/scaffold.ts
+++ b/client/src/components/sidebar/runner/scaffold.ts
@@ -5,6 +5,8 @@ import { ConsoleLine } from './runner'
import { useForceUpdate } from '../../../util/react-util'
import WebSocketListener from './websocket'
import { useAppContext } from '../../../app-context'
+import Game from '../../../playback/Game'
+import Match from '../../../playback/Match'
export type JavaInstall = {
display: string
@@ -115,17 +117,24 @@ export function useScaffold(): Scaffold {
forceUpdate()
})
- setWebSocketListener(
- new WebSocketListener((game) => {
- game.currentMatch = game.matches[0]
- appContext.setState({
- ...appContext.state,
- queue: appContext.state.queue.concat([game]),
- activeGame: game,
- activeMatch: game.currentMatch
- })
- })
- )
+ const onGameCreated = (game: Game) => {
+ appContext.setState((prevState) => ({
+ ...prevState,
+ queue: appContext.state.queue.concat([game]),
+ activeGame: game,
+ activeMatch: game.currentMatch
+ }))
+ }
+
+ const onMatchCreated = (match: Match) => {
+ appContext.setState((prevState) => ({
+ ...prevState,
+ activeGame: match.game,
+ activeMatch: match
+ }))
+ }
+
+ setWebSocketListener(new WebSocketListener(onGameCreated, onMatchCreated, () => {}))
}, [])
useEffect(() => {
@@ -214,16 +223,16 @@ async function findDefaultScaffoldPath(nativeAPI: NativeAPI): Promise schema.EventWrapper | null
@@ -9,11 +12,19 @@ export type FakeGameWrapper = {
export default class WebSocketListener {
url: string = 'ws://localhost:6175'
pollEvery: number = 500
- events: schema.EventWrapper[] = []
- constructor(readonly onGameComplete: (game: Game) => void) {
+ activeGame: Game | null = null
+ constructor(
+ readonly onGameCreated: (game: Game) => void,
+ readonly onMatchCreated: (match: Match) => void,
+ readonly onGameComplete: () => void
+ ) {
this.poll()
}
+ private reset() {
+ this.activeGame = null
+ }
+
private poll() {
const ws = new WebSocket(this.url)
ws.binaryType = 'arraybuffer'
@@ -24,10 +35,10 @@ export default class WebSocketListener {
this.handleEvent(event.data)
}
ws.onerror = (event) => {
- this.events = []
+ this.reset()
}
ws.onclose = (event) => {
- this.events = []
+ this.reset()
window.setTimeout(() => {
this.poll()
}, this.pollEvery)
@@ -36,29 +47,59 @@ export default class WebSocketListener {
private handleEvent(data: ArrayBuffer) {
const event = schema.EventWrapper.getRootAsEventWrapper(new flatbuffers.ByteBuffer(new Uint8Array(data)))
+ const eventType = event.eType()
+
+ if (this.activeGame === null) {
+ assert(eventType === schema.Event.GameHeader, 'First event must be GameHeader')
+ this.sendInitialGame(event)
+ return
+ }
- this.events.push(event)
+ this.activeGame.addEvent(event)
- if (event.eType() === schema.Event.GameHeader) {
- if (this.events.length !== 1) throw new Error('GameHeader event must be first event')
- } else if (event.eType() === schema.Event.GameFooter) {
- if (this.events.length === 1) throw new Error('GameFooter event must be after GameHeader event')
- this.sendCompleteGame()
+ switch (eventType) {
+ case schema.Event.MatchHeader: {
+ const match = this.activeGame.matches[this.activeGame.matches.length - 1]
+ this.sendInitialMatch(match)
+ break
+ }
+ case schema.Event.Round: {
+ const match = this.activeGame.matches[this.activeGame.matches.length - 1]
+ // Auto progress the turn if the user hasn't done it themselves
+ if (match.currentTurn.turnNumber == match.maxTurn - 1) {
+ match.jumpToEnd(true)
+ } else {
+ // Publish anyways so the control bar updates
+ publishEvent(EventType.TURN_PROGRESS, {})
+ }
+ break
+ }
+ case schema.Event.GameFooter: {
+ this.sendCompleteGame()
+ break
+ }
+ default:
+ break
}
}
- private sendCompleteGame() {
+ private sendInitialGame(headerEvent: schema.EventWrapper) {
const fakeGameWrapper: FakeGameWrapper = {
- events: (index: number, unusedEventSlot: any) => {
- return this.events[index]
- },
- eventsLength: () => {
- return this.events.length
- }
+ events: () => headerEvent,
+ eventsLength: () => 1
}
- this.onGameComplete(new Game(fakeGameWrapper))
+ this.activeGame = new Game(fakeGameWrapper)
+
+ this.onGameCreated(this.activeGame)
+ }
- this.events = []
+ private sendInitialMatch(match: Match) {
+ this.onMatchCreated(match)
+ }
+
+ private sendCompleteGame() {
+ this.onGameComplete()
+ this.reset()
}
}
diff --git a/client/src/components/sidebar/sidebar.tsx b/client/src/components/sidebar/sidebar.tsx
index 7ff93a8e..25f6478c 100644
--- a/client/src/components/sidebar/sidebar.tsx
+++ b/client/src/components/sidebar/sidebar.tsx
@@ -50,10 +50,10 @@ export const Sidebar: React.FC = () => {
} else {
setLoadingRemoteTournament(false)
console.log(rawGames)
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
tournament: new Tournament(rawGames)
- })
+ }))
}
})
}
@@ -78,23 +78,23 @@ export const Sidebar: React.FC = () => {
}
const loadedGame = Game.loadFullGameRaw(buffer)
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
activeGame: loadedGame,
activeMatch: loadedGame.currentMatch,
queue: context.state.queue.concat([loadedGame]),
loadingRemoteContent: false
- })
+ }))
setPage(PageType.GAME)
})
}
React.useEffect(() => {
if (gameSource) {
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
loadingRemoteContent: true
- })
+ }))
fetchRemoteGame(gameSource)
setPage(PageType.GAME)
}
diff --git a/client/src/components/sidebar/tournament/tournament.tsx b/client/src/components/sidebar/tournament/tournament.tsx
index f73ec076..8ba696ba 100644
--- a/client/src/components/sidebar/tournament/tournament.tsx
+++ b/client/src/components/sidebar/tournament/tournament.tsx
@@ -18,10 +18,10 @@ export const TournamentPage: React.FC = ({ open, loadingRem
const file = e.target.files[0]
const reader = new FileReader()
reader.onload = () => {
- context.setState({
- ...context.state,
+ context.setState((prevState) => ({
+ ...prevState,
tournament: new Tournament(JSON.parse(reader.result as string) as JsonTournamentGame[])
- })
+ }))
}
reader.readAsText(file)
}
diff --git a/client/src/constants.ts b/client/src/constants.ts
index 2c52eaec..74f69307 100644
--- a/client/src/constants.ts
+++ b/client/src/constants.ts
@@ -1,7 +1,7 @@
import { schema } from 'battlecode-schema'
-export const GAME_VERSION = '1.0.0'
-export const SPEC_VERSION = '1.0.0'
+export const GAME_VERSION = '1.1.0'
+export const SPEC_VERSION = '1.1.0'
export const BATTLECODE_YEAR: number = 2024
export const MAP_SIZE_RANGE = {
min: 30,
diff --git a/client/src/playback/Bodies.ts b/client/src/playback/Bodies.ts
index 20d56c43..142b8596 100644
--- a/client/src/playback/Bodies.ts
+++ b/client/src/playback/Bodies.ts
@@ -152,8 +152,8 @@ export default class Bodies {
// Clear existing indicators
for (const body of this.bodies.values()) {
- body.indicatorDot = null
- body.indicatorLine = null
+ body.indicatorDots = []
+ body.indicatorLines = []
body.indicatorString = ''
}
@@ -165,10 +165,10 @@ export default class Bodies {
// Check if exists because technically can add indicators when not spawned
if (!this.hasId(bodyId)) continue
const body = this.getById(bodyId)
- body.indicatorDot = {
+ body.indicatorDots.push({
location: { x: locs.xs(i)!, y: locs.ys(i)! },
color: renderUtils.rgbToHex(dotColors.red(i)!, dotColors.green(i)!, dotColors.blue(i)!)
- }
+ })
}
// Add new indicator lines
@@ -180,11 +180,11 @@ export default class Bodies {
// Check if exists because technically can add indicators when not spawned
if (!this.hasId(bodyId)) continue
const body = this.getById(bodyId)
- body.indicatorLine = {
+ body.indicatorLines.push({
start: { x: starts.xs(i)!, y: starts.ys(i)! },
end: { x: ends.xs(i)!, y: ends.ys(i)! },
color: renderUtils.rgbToHex(lineColors.red(i)!, lineColors.green(i)!, lineColors.blue(i)!)
- }
+ })
}
// Add new indicator strings
@@ -338,8 +338,8 @@ export class Body {
protected imgPath: string = ''
public nextPos: Vector
private prevSquares: Vector[]
- public indicatorDot: { location: Vector; color: string } | null = null
- public indicatorLine: { start: Vector; end: Vector; color: string } | null = null
+ public indicatorDots: { location: Vector; color: string }[] = []
+ public indicatorLines: { start: Vector; end: Vector; color: string }[] = []
public indicatorString: string = ''
public dead: boolean = false
public jailed: boolean = false
@@ -461,8 +461,7 @@ export class Body {
private drawIndicators(match: Match, ctx: CanvasRenderingContext2D, lighter: boolean): void {
const dimension = match.currentTurn.map.staticMap.dimension
// Render indicator dots
- if (this.indicatorDot) {
- const data = this.indicatorDot
+ for (const data of this.indicatorDots) {
ctx.globalAlpha = lighter ? 0.5 : 1
const coords = renderUtils.getRenderCoords(data.location.x, data.location.y, dimension)
ctx.beginPath()
@@ -473,8 +472,7 @@ export class Body {
}
ctx.lineWidth = INDICATOR_LINE_WIDTH
- if (this.indicatorLine) {
- const data = this.indicatorLine
+ for (const data of this.indicatorLines) {
ctx.globalAlpha = lighter ? 0.5 : 1
const start = renderUtils.getRenderCoords(data.start.x, data.start.y, dimension)
const end = renderUtils.getRenderCoords(data.end.x, data.end.y, dimension)
diff --git a/client/src/playback/Game.ts b/client/src/playback/Game.ts
index 0ddf8c2a..4823ecff 100644
--- a/client/src/playback/Game.ts
+++ b/client/src/playback/Game.ts
@@ -19,7 +19,7 @@ export default class Game {
public readonly matches: Match[] = []
public currentMatch: Match | undefined = undefined
public readonly teams: [Team, Team]
- public readonly winner: Team
+ public winner: Team | null = null
// Metadata
private readonly specVersion: string
@@ -60,8 +60,6 @@ export default class Game {
}
const eventCount = wrapper.eventsLength()
- if (eventCount < 5) throw new Error(`Too few events for well-formed game: ${eventCount}`)
-
const eventSlot = new schema.EventWrapper() // not sure what this is for, probably better performance (this is how it was done in the old client)
// load header and metadata =============================================================================
@@ -88,41 +86,60 @@ export default class Game {
}
this.constants = gameHeader.constants() ?? assert.fail('Constants was null')
- // load matches ==========================================================================================
- for (let i = 1; i < eventCount - 1; i++) {
- const matchHeaderEvent = wrapper.events(i, eventSlot) ?? assert.fail('Event was null')
- assert(matchHeaderEvent.eType() === schema.Event.MatchHeader, 'Event must be MatchHeader')
- const matchHeader = matchHeaderEvent.e(new schema.MatchHeader()) as schema.MatchHeader
-
- i++
- let event
- let matches: schema.Round[] = []
- while (
- (event = wrapper.events(i, eventSlot) ?? assert.fail('Event was null')).eType() !==
- schema.Event.MatchFooter
- ) {
- assert(event.eType() === schema.Event.Round, 'Event must be Round')
- matches.push(event.e(new schema.Round()) as schema.Round)
- i++
- }
-
- assert(event.eType() === schema.Event.MatchFooter, 'Event must be MatchFooter')
- const matchFooter = event.e(new schema.MatchFooter()) as schema.MatchFooter
-
- this.matches.push(Match.fromSchema(this, matchHeader, matches, matchFooter))
+ // load all other events ==========================================================================================
+ for (let i = 1; i < eventCount; i++) {
+ const event = wrapper.events(i, eventSlot) ?? assert.fail('Event was null')
+ this.addEvent(event)
}
- if (!this.currentMatch && this.matches.length > 0) this.currentMatch = this.matches[0]
-
- // load footer ==========================================================================================
- const event = wrapper.events(eventCount - 1, eventSlot) ?? assert.fail('Event was null')
- assert(event.eType() === schema.Event.GameFooter, 'Last event must be GameFooter')
- const gameFooter = event.e(new schema.GameFooter()) as schema.GameFooter
- this.winner = this.teams[gameFooter.winner() - 1]
-
this.id = nextID++
}
+ /*
+ * Adds a new game event to the game. Used for live match replaying.
+ */
+ public addEvent(event: schema.EventWrapper): void {
+ switch (event.eType()) {
+ case schema.Event.GameHeader: {
+ assert(false, 'Cannot add another GameHeader event to Game')
+ }
+ case schema.Event.MatchHeader: {
+ const header = event.e(new schema.MatchHeader()) as schema.MatchHeader
+ this.matches.push(Match.fromSchema(this, header, []))
+ this.currentMatch = this.matches[this.matches.length - 1]
+ return
+ }
+ case schema.Event.Round: {
+ assert(
+ this.matches.length > 0,
+ 'Cannot add Round event to Game if no MatchHeaders have been added first'
+ )
+ const round = event.e(new schema.Round()) as schema.Round
+ this.matches[this.matches.length - 1].addNewTurn(round)
+ return
+ }
+ case schema.Event.MatchFooter: {
+ assert(
+ this.matches.length > 0,
+ 'Cannot add MatchFooter event to Game if no MatchHeaders have been added first'
+ )
+ const footer = event.e(new schema.MatchFooter()) as schema.MatchFooter
+ this.matches[this.matches.length - 1].addMatchFooter(footer)
+ return
+ }
+ case schema.Event.GameFooter: {
+ assert(this.winner === null, 'Cannot add another GameFooter event to Game')
+ const footer = event.e(new schema.GameFooter()) as schema.GameFooter
+ this.winner = this.teams[footer.winner() - 1]
+ return
+ }
+ default: {
+ console.log(`Unknown event type: ${event.eType()}`)
+ return
+ }
+ }
+ }
+
public getTeamByID(id: number): Team {
for (const team of this.teams) if (team.id === id) return team
throw new Error('Team not found')
diff --git a/client/src/playback/Match.ts b/client/src/playback/Match.ts
index 5ba029ae..bc49da46 100644
--- a/client/src/playback/Match.ts
+++ b/client/src/playback/Match.ts
@@ -25,9 +25,9 @@ export default class Match {
constructor(
public readonly game: Game,
private readonly deltas: schema.Round[],
- public readonly maxTurn: number,
- public readonly winner: Team,
- public readonly winType: schema.WinType,
+ public maxTurn: number,
+ public winner: Team | null,
+ public winType: schema.WinType | null,
public readonly map: StaticMap,
firstBodies: Bodies,
firstStats: TurnStat
@@ -59,11 +59,8 @@ export default class Match {
game: Game,
header: schema.MatchHeader,
turns: schema.Round[],
- footer: schema.MatchFooter
+ footer?: schema.MatchFooter
) {
- const winner = game.teams[footer.winner() - 1]
- const winType = footer.winType()
-
const mapData = header.map() ?? assert.fail('Map data not found in header')
const map = StaticMap.fromSchema(mapData)
@@ -83,7 +80,26 @@ export default class Match {
const maxTurn = deltas.length
- return new Match(game, deltas, maxTurn, winner, winType, map, firstBodies, firstStats)
+ const match = new Match(game, deltas, maxTurn, null, null, map, firstBodies, firstStats)
+ if (footer) match.addMatchFooter(footer)
+
+ return match
+ }
+
+ /*
+ * Add a new turn to the match. Used for live match replaying.
+ */
+ public addNewTurn(round: schema.Round): void {
+ this.deltas.push(round)
+ this.maxTurn++
+ }
+
+ /*
+ * Add the match footer to the match. Used for live match replaying.
+ */
+ public addMatchFooter(footer: schema.MatchFooter): void {
+ this.winner = this.game.teams[footer.winner() - 1]
+ this.winType = footer.winType()
}
/**
diff --git a/engine/src/main/battlecode/common/GameConstants.java b/engine/src/main/battlecode/common/GameConstants.java
index 8c1440b3..9e884c15 100644
--- a/engine/src/main/battlecode/common/GameConstants.java
+++ b/engine/src/main/battlecode/common/GameConstants.java
@@ -9,7 +9,7 @@ public class GameConstants {
/**
* The current spec version the server compiles with.
*/
- public static final String SPEC_VERSION = "1.0.0";
+ public static final String SPEC_VERSION = "1.1.0";
// *********************************
// ****** MAP CONSTANTS ************
@@ -89,6 +89,9 @@ public class GameConstants {
/** The amount of crumbs each team gains per turn. */
public static final int PASSIVE_CRUMBS_INCREASE = 10;
+ /** The amount of crumbs you gain if your bot kills an enemy while in enemy territory */
+ public static final int KILL_CRUMB_REWARD = 50;
+
/** The end of the setup rounds in the game */
public static final int SETUP_ROUNDS = 200;
@@ -96,7 +99,7 @@ public class GameConstants {
public static final int GLOBAL_UPGRADE_ROUNDS = 750;
/** Number of rounds robots must spend in jail before respawning */
- public static final int JAILED_ROUNDS = 10;
+ public static final int JAILED_ROUNDS = 25;
/** The maximum distance from a robot where information can be sensed */
public static final int VISION_RADIUS_SQUARED = 20;
diff --git a/engine/src/main/battlecode/common/GlobalUpgrade.java b/engine/src/main/battlecode/common/GlobalUpgrade.java
index 29c2f976..51bad116 100644
--- a/engine/src/main/battlecode/common/GlobalUpgrade.java
+++ b/engine/src/main/battlecode/common/GlobalUpgrade.java
@@ -8,7 +8,7 @@
public enum GlobalUpgrade {
/**
- * Action upgrade increases the amount cooldown drops per round by 6.
+ * Action upgrade increases the amount cooldown drops per round by 4.
*/
ACTION(4, 0, 0),
@@ -42,4 +42,4 @@ public enum GlobalUpgrade {
this.baseHealChange = baseHealChange;
this.flagReturnDelayChange = flagReturnDelayChange;
}
-}
\ No newline at end of file
+}
diff --git a/engine/src/main/battlecode/common/RobotInfo.java b/engine/src/main/battlecode/common/RobotInfo.java
index 7a020092..ce223dad 100644
--- a/engine/src/main/battlecode/common/RobotInfo.java
+++ b/engine/src/main/battlecode/common/RobotInfo.java
@@ -27,15 +27,36 @@ public class RobotInfo {
*/
public final MapLocation location;
+ /**
+ * Whether or not the robot is holding a flag.
+ */
public final boolean hasFlag;
- public RobotInfo(int ID, Team team, int health, MapLocation location, boolean hasFlag) {
+ /**
+ * The robot's current level in the attack skill.
+ */
+ public final int attackLevel;
+
+ /**
+ * The robot's current level in the heal skill.
+ */
+ public final int healLevel;
+
+ /**
+ * The robot's current level in the build skill.
+ */
+ public final int buildLevel;
+
+ public RobotInfo(int ID, Team team, int health, MapLocation location, boolean hasFlag, int attackLevel, int healLevel, int buildLevel) {
super();
this.ID = ID;
this.team = team;
this.health = health;
this.location = location;
this.hasFlag = hasFlag;
+ this.attackLevel = attackLevel;
+ this.healLevel = healLevel;
+ this.buildLevel = buildLevel;
}
/**
@@ -74,10 +95,42 @@ public MapLocation getLocation() {
return this.location;
}
+ /**
+ * Returns whether or not this robot has a flag.
+ *
+ * @return whether or not this robot has a flag
+ */
public boolean hasFlag() {
return this.hasFlag;
}
+ /**
+ * Returns the attack level of this robot.
+ *
+ * @return the attack level of the robot
+ */
+ public int getAttackLevel(){
+ return this.attackLevel;
+ }
+
+ /**
+ * Returns the heal level of this robot.
+ *
+ * @return the heal level of the robot
+ */
+ public int getHealLevel(){
+ return this.healLevel;
+ }
+
+ /**
+ * Returns the build level of this robot.
+ *
+ * @return the build level of the robot
+ */
+ public int getBuildLevel(){
+ return this.buildLevel;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/engine/src/main/battlecode/common/SkillType.java b/engine/src/main/battlecode/common/SkillType.java
index a94d0b94..4d66120e 100644
--- a/engine/src/main/battlecode/common/SkillType.java
+++ b/engine/src/main/battlecode/common/SkillType.java
@@ -37,7 +37,7 @@ public enum SkillType{
public int getExperience(int level){
int[] attackExperience = {0, 20, 40, 70, 100, 140, 180};
int[] buildExperience = {0, 5, 10, 15, 20, 25, 30};
- int[] healExperience = {0, 10, 20, 30, 50, 75, 125};
+ int[] healExperience = {0, 15, 30, 45, 75, 110, 150};
switch(this){
case ATTACK: return attackExperience[level];
case BUILD: return buildExperience[level];
@@ -110,6 +110,20 @@ public int getPenalty(int level){
return 0;
}
+ /**
+ * Returns the level of specialization a robot is given their experience
+ *
+ * @param experience how much experience in the skill the robot has
+ * @return the level in the skill the robot is
+ *
+ * @battlecode.doc.costlymethod
+ */
+ public int getLevel(int experience){
+ int level = 0;
+ while (level < 6 && this.getExperience(level+1) <= experience ) level += 1;
+ return level;
+ }
+
SkillType(int skillEffect, int cooldown){
this.skillEffect = skillEffect;
this.cooldown = cooldown;
diff --git a/engine/src/main/battlecode/instrumenter/bytecode/resources/MethodCosts.txt b/engine/src/main/battlecode/instrumenter/bytecode/resources/MethodCosts.txt
index c9a8432d..0e8657ce 100644
--- a/engine/src/main/battlecode/instrumenter/bytecode/resources/MethodCosts.txt
+++ b/engine/src/main/battlecode/instrumenter/bytecode/resources/MethodCosts.txt
@@ -97,6 +97,7 @@ battlecode/common/SkillType/getExperience 3 fal
battlecode/common/SkillType/getCooldown 3 false
battlecode/common/SkillType/getSkillEffect 3 false
battlecode/common/SkillType/getPenalty 3 false
+battlecode/common/SkillType/getLevel 3 false
battlecode/common/RobotInfo/getID 1 false
battlecode/common/RobotInfo/getTeam 1 false
battlecode/common/RobotInfo/getHealth 2 false
diff --git a/engine/src/main/battlecode/world/GameWorld.java b/engine/src/main/battlecode/world/GameWorld.java
index 3c33c054..29948b64 100644
--- a/engine/src/main/battlecode/world/GameWorld.java
+++ b/engine/src/main/battlecode/world/GameWorld.java
@@ -32,6 +32,7 @@ public strictfp class GameWorld {
private boolean[] water;
private boolean[] dams;
private int[] spawnZones; // Team A = 1, Team B = 2, not spawn zone = 0
+ private int[] teamSides; //Team A territory = 1, Team B territory = 2, dam = 0
private MapLocation[][] spawnLocations;
private int[] breadAmounts;
private ArrayList[] trapTriggers;
@@ -68,6 +69,7 @@ public GameWorld(LiveMap gm, RobotControlProvider cp, GameMaker.MatchMaker match
this.gameStats = new GameStats();
this.gameMap = gm;
this.objectInfo = new ObjectInfo(gm);
+ teamSides = new int[gameMap.getWidth() * gameMap.getHeight()];
this.profilerCollections = new HashMap<>();
@@ -134,8 +136,10 @@ else if (this.spawnZones[i] == 2){
this.spawnLocations[1][curB] = indexToLocation(i);
curB += 1;
}
-
}
+
+ floodFillTeam(1, getSpawnLocations(Team.A)[0]);
+ floodFillTeam(2, getSpawnLocations(Team.B)[0]);
}
/**
@@ -289,6 +293,10 @@ public int getSpawnZone(MapLocation loc) {
return this.spawnZones[locationToIndex(loc)];
}
+ public int getTeamSide(MapLocation loc) {
+ return teamSides[locationToIndex(loc)];
+ }
+
public boolean isPassable(MapLocation loc) {
if (currentRound <= GameConstants.SETUP_ROUNDS){
return !this.walls[locationToIndex(loc)] && !this.water[locationToIndex(loc)] && !this.dams[locationToIndex(loc)];
@@ -714,6 +722,33 @@ private void moveFlagSetStartLoc(Flag flag, MapLocation location){
matchMaker.addAction(flag.getId(), Action.PLACE_FLAG, locationToIndex(location));
flag.setStartLoc(location);
}
+
+ private void floodFillTeam(int teamVal, MapLocation start) {
+ System.out.println(start);
+ Queue queue = new LinkedList();
+ queue.add(start);
+
+ while (!queue.isEmpty()) {
+ MapLocation loc = queue.remove();
+ int idx = locationToIndex(loc);
+
+ if(teamSides[idx] != 0) continue;
+ teamSides[idx] = teamVal;
+
+ for (Direction dir : Direction.allDirections()) {
+ if (dir != Direction.CENTER) {
+ MapLocation newLoc = loc.add(dir);
+
+ if (gameMap.onTheMap(newLoc)) {
+ int newIdx = locationToIndex(newLoc);
+ if (teamSides[newIdx] == 0 && !walls[newIdx] && !dams[newIdx]) {
+ queue.add(newLoc);
+ }
+ }
+ }
+ }
+ }
+ }
// *********************************
// ****** SPAWNING *****************
diff --git a/engine/src/main/battlecode/world/InternalRobot.java b/engine/src/main/battlecode/world/InternalRobot.java
index a97a29cc..e8d1fa32 100644
--- a/engine/src/main/battlecode/world/InternalRobot.java
+++ b/engine/src/main/battlecode/world/InternalRobot.java
@@ -187,11 +187,15 @@ public RobotInfo getRobotInfo() {
&& cachedRobotInfo.ID == ID
&& cachedRobotInfo.team == team
&& cachedRobotInfo.health == health
- && cachedRobotInfo.location.equals(location)) {
+ && cachedRobotInfo.location.equals(location)
+ && cachedRobotInfo.attackLevel == SkillType.ATTACK.getLevel(attackExp)
+ && cachedRobotInfo.healLevel == SkillType.HEAL.getLevel(healExp)
+ && cachedRobotInfo.buildLevel == SkillType.BUILD.getLevel(buildExp)) {
return cachedRobotInfo;
}
- this.cachedRobotInfo = new RobotInfo(ID, team, health, location, flag != null);
+ this.cachedRobotInfo = new RobotInfo(ID, team, health, location, flag != null,
+ SkillType.ATTACK.getLevel(attackExp), SkillType.HEAL.getLevel(healExp), SkillType.BUILD.getLevel(buildExp));
return this.cachedRobotInfo;
}
@@ -410,6 +414,14 @@ public void attack(MapLocation loc) {
this.getGameWorld().getMatchMaker().addAction(getID(), Action.ATTACK, -locationToInt(loc) - 1);
} else {
int dmg = getDamage();
+
+ int newEnemyHealth = bot.getHealth() - dmg;
+ if(newEnemyHealth <= 0) {
+ if(gameWorld.getTeamSide(getLocation()) == team.opponent().ordinal()) {
+ addResourceAmount(GameConstants.KILL_CRUMB_REWARD);
+ }
+ }
+
bot.addHealth(-dmg);
incrementSkill(SkillType.ATTACK);
this.gameWorld.getMatchMaker().addAction(getID(), Action.ATTACK, bot.getID());
diff --git a/engine/src/main/battlecode/world/LiveMap.java b/engine/src/main/battlecode/world/LiveMap.java
index c5f0c618..dcb67349 100644
--- a/engine/src/main/battlecode/world/LiveMap.java
+++ b/engine/src/main/battlecode/world/LiveMap.java
@@ -447,114 +447,113 @@ private int getOpposingTeamNumber(int team) {
}
}
- // WARNING: POSSIBLY BUGGY
- private void assertSpawnZonesAreValid() {
- int numSquares = this.width * this.height;
- boolean[] alreadyChecked = new boolean[numSquares];
-
- for (int i = 0; i < numSquares; i++) {
- int team = this.spawnZoneArray[i];
-
- // if the square is actually a spawn zone
-
- if (isTeamNumber(team)) {
- boolean bad = floodFillMap(indexToLocation(i),
- (loc) -> this.spawnZoneArray[locationToIndex(loc)] == getOpposingTeamNumber(team),
- (loc) -> this.wallArray[locationToIndex(loc)] || this.damArray[locationToIndex(loc)],
- alreadyChecked);
-
- if (bad) {
- throw new RuntimeException("Two spawn zones for opposing teams can reach each other.");
- }
+ // WARNING: POSSIBLY BUGGY
+ private void assertSpawnZonesAreValid() {
+ int numSquares = this.width * this.height;
+ boolean[] alreadyChecked = new boolean[numSquares];
+
+ for (int i = 0; i < numSquares; i++) {
+ int team = this.spawnZoneArray[i];
+
+ // if the square is actually a spawn zone
+
+ if (isTeamNumber(team)) {
+ boolean bad = floodFillMap(indexToLocation(i),
+ (loc) -> this.spawnZoneArray[locationToIndex(loc)] == getOpposingTeamNumber(team),
+ (loc) -> this.wallArray[locationToIndex(loc)] || this.damArray[locationToIndex(loc)],
+ alreadyChecked);
+
+ if (bad) {
+ throw new RuntimeException("Two spawn zones for opposing teams can reach each other.");
}
}
}
+ }
- private void assertSpawnZoneDistances() {
- ArrayList team1 = new ArrayList();
- ArrayList team2 = new ArrayList();
-
- int[][] spawnZoneCenters = getSpawnZoneCenters();
- for(int i = 0; i < spawnZoneCenters.length; i ++){
- if (i % 2 == 0){
- team1.add(new MapLocation(spawnZoneCenters[i][0], spawnZoneCenters[i][1]));
- }
- else {
- team2.add(new MapLocation(spawnZoneCenters[i][0], spawnZoneCenters[i][1]));
- }
+ private void assertSpawnZoneDistances() {
+ ArrayList team1 = new ArrayList();
+ ArrayList team2 = new ArrayList();
+
+ int[][] spawnZoneCenters = getSpawnZoneCenters();
+ for(int i = 0; i < spawnZoneCenters.length; i ++){
+ if (i % 2 == 0){
+ team1.add(new MapLocation(spawnZoneCenters[i][0], spawnZoneCenters[i][1]));
}
-
- for(int a = 0; a < team1.size()-1; a ++){
- for(int b = a+1; b < team1.size(); b ++){
- if ((team1.get(a)).distanceSquaredTo((team1.get(b))) < GameConstants.MIN_FLAG_SPACING_SQUARED){
- throw new RuntimeException("Two spawn zones on the same team are within 6 units of each other");
- }
+ else {
+ team2.add(new MapLocation(spawnZoneCenters[i][0], spawnZoneCenters[i][1]));
+ }
+ }
+
+ for(int a = 0; a < team1.size()-1; a ++){
+ for(int b = a+1; b < team1.size(); b ++){
+ if ((team1.get(a)).distanceSquaredTo((team1.get(b))) < GameConstants.MIN_FLAG_SPACING_SQUARED){
+ throw new RuntimeException("Two spawn zones on the same team are within 6 units of each other");
}
}
-
- for(int c = 0; c < team2.size()-1; c ++){
- for(int d = c+1; d < team2.size(); d ++){
- if ((team2.get(c)).distanceSquaredTo((team2.get(d))) < GameConstants.MIN_FLAG_SPACING_SQUARED){
- throw new RuntimeException("Two spawn zones on the same team are within 6 units of each other");
- }
+ }
+
+ for(int c = 0; c < team2.size()-1; c ++){
+ for(int d = c+1; d < team2.size(); d ++){
+ if ((team2.get(c)).distanceSquaredTo((team2.get(d))) < GameConstants.MIN_FLAG_SPACING_SQUARED){
+ throw new RuntimeException("Two spawn zones on the same team are within 6 units of each other");
}
}
}
-
- /**
- * Performs a flood fill algorithm to check if a predicate is true for any squares
- * that can be reached from a given location (horizontal, vertical, and diagonal steps allowed).
- *
- * @param startLoc the starting location
- * @param checkForBad the predicate to check for each reachable square
- * @param checkForWall a predicate that checks if the given square has a wall
- * @param alreadyChecked an array indexed by map location indices which has "true" at
- * every location reachable from a spawn zone that has already been checked
- * (WARNING: this array gets updated by floodFillMap)
- * @return if checkForBad returns true for any reachable squares
- */
- private boolean floodFillMap(MapLocation startLoc, Predicate checkForBad, Predicate checkForWall, boolean[] alreadyChecked) {
- Queue queue = new LinkedList(); // stores map locations by index
-
- if (!onTheMap(startLoc)) {
- throw new RuntimeException("Cannot call floodFillMap with startLocation off the map.");
+ }
+
+ /**
+ * Performs a flood fill algorithm to check if a predicate is true for any squares
+ * that can be reached from a given location (horizontal, vertical, and diagonal steps allowed).
+ *
+ * @param startLoc the starting location
+ * @param checkForBad the predicate to check for each reachable square
+ * @param checkForWall a predicate that checks if the given square has a wall
+ * @param alreadyChecked an array indexed by map location indices which has "true" at
+ * every location reachable from a spawn zone that has already been checked
+ * (WARNING: this array gets updated by floodFillMap)
+ * @return if checkForBad returns true for any reachable squares
+ */
+ private boolean floodFillMap(MapLocation startLoc, Predicate checkForBad, Predicate checkForWall, boolean[] alreadyChecked) {
+ Queue queue = new LinkedList(); // stores map locations by index
+
+ if (!onTheMap(startLoc)) {
+ throw new RuntimeException("Cannot call floodFillMap with startLocation off the map.");
+ }
+
+ queue.add(startLoc);
+
+ while (!queue.isEmpty()) {
+ MapLocation loc = queue.remove();
+ int idx = locationToIndex(loc);
+
+ if (alreadyChecked[idx]) {
+ continue;
}
-
- queue.add(startLoc);
-
- while (!queue.isEmpty()) {
- MapLocation loc = queue.remove();
- int idx = locationToIndex(loc);
-
- if (alreadyChecked[idx]) {
- continue;
+
+ alreadyChecked[idx] = true;
+
+ if (!checkForWall.test(loc)) {
+ if (checkForBad.test(loc)) {
+ return true;
}
-
- alreadyChecked[idx] = true;
-
- if (!checkForWall.test(loc)) {
- if (checkForBad.test(loc)) {
- return true;
- }
-
- for (Direction dir : Direction.allDirections()) {
- if (dir != Direction.CENTER) {
- MapLocation newLoc = loc.add(dir);
-
- if (onTheMap(newLoc)) {
- int newIdx = locationToIndex(newLoc);
-
- if (!(alreadyChecked[newIdx] || checkForWall.test(newLoc))) {
- queue.add(newLoc);
- }
+
+ for (Direction dir : Direction.allDirections()) {
+ if (dir != Direction.CENTER) {
+ MapLocation newLoc = loc.add(dir);
+
+ if (onTheMap(newLoc)) {
+ int newIdx = locationToIndex(newLoc);
+
+ if (!(alreadyChecked[newIdx] || checkForWall.test(newLoc))) {
+ queue.add(newLoc);
}
}
}
}
}
-
- return false;
}
+ return false;
+ }
@Override
diff --git a/engine/src/main/battlecode/world/RobotControllerImpl.java b/engine/src/main/battlecode/world/RobotControllerImpl.java
index 84e1b552..a48bbe0a 100644
--- a/engine/src/main/battlecode/world/RobotControllerImpl.java
+++ b/engine/src/main/battlecode/world/RobotControllerImpl.java
@@ -666,8 +666,6 @@ public void fill(MapLocation loc) throws GameActionException{
if (this.gameWorld.hasTrap(loc) && this.gameWorld.getTrap(loc).getTeam() != getTeam() && this.gameWorld.getTrap(loc).getType() == TrapType.EXPLOSIVE){
this.robot.addTrapTrigger(this.gameWorld.getTrap(loc), false);
}
-
- this.robot.incrementSkill(SkillType.BUILD);
}
private void assertCanDig(MapLocation loc) throws GameActionException {
@@ -687,7 +685,9 @@ private void assertCanDig(MapLocation loc) throws GameActionException {
if (this.gameWorld.hasFlag(loc))
throw new GameActionException(CANT_DO_THAT, "Cannot dig under a tile with a flag currently on it.");
if(this.robot.hasFlag())
- throw new GameActionException(CANT_DO_THAT, "Can't dig while holding a flag");
+ throw new GameActionException(CANT_DO_THAT, "Cannot dig while holding a flag");
+ if (this.gameWorld.hasTrap(loc) && this.gameWorld.getTrap(loc).getTeam() == getTeam())
+ throw new GameActionException(CANT_DO_THAT, "Cannot dig on a tile with one of your team's traps on it.");
}
@Override
@@ -888,6 +888,15 @@ public void pickupFlag(MapLocation loc) throws GameActionException {
robot.addActionCooldownTurns(GameConstants.PICKUP_DROP_COOLDOWN);
gameWorld.getMatchMaker().addAction(robot.getID(), Action.PICKUP_FLAG, tempflag.getId());
this.gameWorld.getTeamInfo().pickupFlag(getTeam());
+
+ Team[] allSpawnZones = {null, Team.A, Team.B};
+ if (tempflag.getTeam() != this.robot.getTeam() && allSpawnZones[this.gameWorld.getSpawnZone(getLocation())] == this.getTeam()) {
+ this.gameWorld.getTeamInfo().captureFlag(this.getTeam());
+ this.gameWorld.getMatchMaker().addAction(getID(), Action.CAPTURE_FLAG, robot.getFlag().getId());
+ robot.getFlag().setLoc(null);
+ gameWorld.getAllFlags().remove(robot.getFlag());
+ this.robot.removeFlag();
+ }
}
// ***********************************
diff --git a/specs/specs.md.html b/specs/specs.md.html
index a25e5dc3..178a2bf3 100644
--- a/specs/specs.md.html
+++ b/specs/specs.md.html
@@ -16,7 +16,7 @@
# **Formal specification**
-_This is the formal specification of the Battlecode 2024 game._ Current version: *1.0.0*
+_This is the formal specification of the Battlecode 2024 game._ Current version: *1.1.0*
**Welcome to Battlecode 2024: Breadwars.**
@@ -77,13 +77,17 @@
# **Units**
-Every unit is initially identical with the same base stats and attributes. The maximum number of units for each team is **50**. Each unit has a base health of **1000**.
+The game is turn-based and divided into rounds. In each round, every robot gets a turn in which it gets a chance to run code and take actions. Code that a robot runs costs bytecodes, a measure of computational resources. A robot only has a predetermined amount of bytecodes available per turn, after which the robot's turn is immediately ended and computations are resumed on its next turn. If your robot has finished its turn, it should call Clock.yield() to wait for the next turn to begin.
-Robots are assigned unique random IDs no smaller than 10,000. This ID stays the same even after a robot dies and respawns.
+All robots have **1000** HP (also known as hitpoints, health, life, or such). When a robot's HP reaches zero, the robot is sent to jail (described below).
+
+Robots are assigned unique random IDs no smaller than 10,000. Robots interact with only their nearby surroundings through sensing, moving, and other actions. Each robot runs an independent copy of your code. Robots will be unable to share static variables (they will each have their own copy), because they are run in separate JVMs.
+
+Every unit is initially identical with the same base stats and attributes. Each team always has **50** robots, although robots are not all necessarily spawned into the game at all times.
**Spawning**
-At the start of the game, no units are present on the map. All units must be manually spawned in by specifying a location in one of the spawn zones.
+At the start of the game, no units are present on the map. All units must be manually spawned in by specifying a location in one of the spawn zones. All 50 robots still run their code once per turn regardless of if they are spawned, but all actions besides spawning are impossible when not spawned.
**Specializations**
@@ -110,7 +114,7 @@
**Attacking**
-Robots can attack enemy robots within **2** tiles. Robots may only attack tiles that contain an enemy robot (no missed attacks are allowed). Attacking incurs a base health penalty of **-150** points to the enemy robot and adds **+20** to the attacking robot’s action cooldown.
+Robots can attack enemy robots within **2** tiles. Robots may only attack tiles that contain an enemy robot (no missed attacks are allowed). Attacking incurs a base health penalty of **-150** points to the enemy robot and adds **+20** to the attacking robot’s action cooldown. If your robot kills an enemy robot while in enemy territory (tiles that were originally on the enemy’s side of the dam), your team gains **50** crumbs.
**Healing**
@@ -152,7 +156,7 @@
| Name | Cost | Function | Action cooldown
| --- | --- | --- | ---
-| Explosive trap | 250 crumbs | Can be built on land or in water. When an opponent robot enters the cell containing this trap, it explodes dealing **750** damage to all opponent robots within a radius of $\sqrt{13}$ cells. When an opponent robot digs, fills, or tries to build on the trap, it explodes dealing **500** damage to all opponent robots within a radius of 9 cells. | 5
+| Explosive trap | 250 crumbs | Can be built on land or in water. When an opponent robot enters the cell containing this trap, it explodes dealing **750** damage to all opponent robots within a radius of $\sqrt{13}$ cells. When an opponent robot digs, fills, or tries to build on the trap, it explodes dealing **500** damage to all opponent robots within a radius of $\sqrt{9}$ cells. | 5
| Water trap | 100 crumbs | Can only be built on land. Digs all non-occupied land in a radius of $\sqrt{9}$ when an opponent robot enters an adjacent tile. | 5
| Stun trap | 100 crumbs | Can only be built on land. Stuns all enemy robots in a radius of $\sqrt{13}$ when an opponent enters an adjacent tile, setting all of those robots’ movement and action cooldowns to **40**. | 5
@@ -162,7 +166,7 @@
| Name | Function
| --- | --- |
-| Action Upgrade - Swift Beaks | Increases per-round cooldown reduction by **4**.
+| Action Upgrade - Swift Beaks | Increases per-round action cooldown reduction by **+4**.
| Healing Upgrade - Down Feathers | Increases base heal by **+50** health points.
| Capturing Upgrade - Heavy Bread | Increases the dropped flag return delay of the other team’s flag to **12** rounds.
@@ -172,12 +176,12 @@
| Level | Attack Exp | Build Exp | Heal Exp
| --- | --- | --- | ---
| 0 | 0 | 0 | 0
-| 1 | 20 | 5 | 10
-| 2 | 40 | 10 | 20
-| 3 | 70 | 15 | 30
-| 4 | 100 | 20 | 50
-| 5 | 140 | 25 | 75
-| 6 | 180 | 30 | 125
+| 1 | 20 | 5 | 15
+| 2 | 40 | 10 | 30
+| 3 | 70 | 15 | 45
+| 4 | 100 | 20 | 75
+| 5 | 140 | 25 | 110
+| 6 | 180 | 30 | 150
**Specialization Jailed Penalty**
| Level | Attack | Build | Heal
@@ -280,6 +284,28 @@
If something is unclear, direct your questions to our Discord where other people may have the same question. We'll update this spec as the competition progresses.
+# **Appendix: Changelog**
+
+- Version 1.1.0
+ - Spec fixes
+ - Clarified general robot behavior in intro of Units section
+ - Clarified spawning mechanics
+ - Fixed typos
+ - Balance changes
+ - Killing a robot while in enemy territory now results in a reward of **50** crumbs
+ - Filling no longer yields building XP (but still receives benefits from building level)
+ - Jailed rounds increased from **10** to **25**
+ - Increased healing specialization XP requirements (see table)
+ - Engine fixes
+ - Picking up an enemy flag in a friendly spawn zone now results in capture
+ - Robots can no longer dig on friendly traps
+ - Client improvements
+ - Games executed in the runner now display live as the game is being played
+ - Multiple indicator dots & lines per robot can be visualized
+ - Attempted fix for client config being reset between games
+ - Added new spawn zone map guarantee checks in the map editor
+ - Fixed some issues with the default scaffold locator
+ - Fixed overscrolling and overflow styling issues
## Notes