Spaces:
Sleeping
Sleeping
import { Infer, ObjectType, v } from 'convex/values'; | |
import { Point, Vector, path, point, vector } from '../util/types'; | |
import { GameId, parseGameId } from './ids'; | |
import { playerId } from './ids'; | |
import { | |
PATHFINDING_TIMEOUT, | |
PATHFINDING_BACKOFF, | |
HUMAN_IDLE_TOO_LONG, | |
MAX_HUMAN_PLAYERS, | |
MAX_PATHFINDS_PER_STEP, | |
} from '../constants'; | |
import { pointsEqual, pathPosition } from '../util/geometry'; | |
import { Game } from './game'; | |
import { stopPlayer, findRoute, blocked, movePlayer } from './movement'; | |
import { inputHandler } from './inputHandler'; | |
import { characters } from '../../data/characters'; | |
import { CharacterType, CharacterTypeSchema, PlayerDescription } from './playerDescription'; | |
import { gameVote, llmVote } from './voting'; | |
const pathfinding = v.object({ | |
destination: point, | |
started: v.number(), | |
state: v.union( | |
v.object({ | |
kind: v.literal('needsPath'), | |
}), | |
v.object({ | |
kind: v.literal('waiting'), | |
until: v.number(), | |
}), | |
v.object({ | |
kind: v.literal('moving'), | |
path, | |
}), | |
), | |
}); | |
export type Pathfinding = Infer<typeof pathfinding>; | |
export const activity = v.object({ | |
description: v.string(), | |
emoji: v.optional(v.string()), | |
until: v.number(), | |
}); | |
export type Activity = Infer<typeof activity>; | |
export const serializedPlayer = { | |
id: playerId, | |
human: v.optional(v.string()), | |
pathfinding: v.optional(pathfinding), | |
activity: v.optional(activity), | |
// The last time they did something. | |
lastInput: v.number(), | |
position: point, | |
facing: vector, | |
speed: v.number(), | |
}; | |
export type SerializedPlayer = ObjectType<typeof serializedPlayer>; | |
export class Player { | |
id: GameId<'players'>; | |
human?: string; | |
pathfinding?: Pathfinding; | |
activity?: Activity; | |
lastInput: number; | |
position: Point; | |
facing: Vector; | |
speed: number; | |
constructor(serialized: SerializedPlayer) { | |
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = serialized; | |
this.id = parseGameId('players', id); | |
this.human = human; | |
this.pathfinding = pathfinding; | |
this.activity = activity; | |
this.lastInput = lastInput; | |
this.position = position; | |
this.facing = facing; | |
this.speed = speed; | |
} | |
playerType(game: Game) { | |
const playerDescription = game.playerDescriptions.get(this.id) | |
return playerDescription?.type; | |
} | |
tick(game: Game, now: number) { | |
if (this.human && this.lastInput < now - HUMAN_IDLE_TOO_LONG) { | |
this.leave(game, now); | |
} | |
} | |
tickPathfinding(game: Game, now: number) { | |
// There's nothing to do if we're not moving. | |
const { pathfinding, position } = this; | |
if (!pathfinding) { | |
return; | |
} | |
// Stop pathfinding if we've reached our destination. | |
if (pathfinding.state.kind === 'moving' && pointsEqual(pathfinding.destination, position)) { | |
stopPlayer(this); | |
} | |
// Stop pathfinding if we've timed out. | |
if (pathfinding.started + PATHFINDING_TIMEOUT < now) { | |
console.warn(`Timing out pathfinding for ${this.id}`); | |
stopPlayer(this); | |
} | |
// Transition from "waiting" to "needsPath" if we're past the deadline. | |
if (pathfinding.state.kind === 'waiting' && pathfinding.state.until < now) { | |
pathfinding.state = { kind: 'needsPath' }; | |
} | |
// Perform pathfinding if needed. | |
if (pathfinding.state.kind === 'needsPath' && game.numPathfinds < MAX_PATHFINDS_PER_STEP) { | |
game.numPathfinds++; | |
if (game.numPathfinds === MAX_PATHFINDS_PER_STEP) { | |
console.warn(`Reached max pathfinds for this step`); | |
} | |
const route = findRoute(game, now, this, pathfinding.destination); | |
if (route === null) { | |
console.log(`Failed to route to ${JSON.stringify(pathfinding.destination)}`); | |
stopPlayer(this); | |
} else { | |
if (route.newDestination) { | |
console.warn( | |
`Updating destination from ${JSON.stringify( | |
pathfinding.destination, | |
)} to ${JSON.stringify(route.newDestination)}`, | |
); | |
pathfinding.destination = route.newDestination; | |
} | |
pathfinding.state = { kind: 'moving', path: route.path }; | |
} | |
} | |
} | |
tickPosition(game: Game, now: number) { | |
// There's nothing to do if we're not moving. | |
if (!this.pathfinding || this.pathfinding.state.kind !== 'moving') { | |
this.speed = 0; | |
return; | |
} | |
// Compute a candidate new position and check if it collides | |
// with anything. | |
const candidate = pathPosition(this.pathfinding.state.path as any, now); | |
if (!candidate) { | |
console.warn(`Path out of range of ${now} for ${this.id}`); | |
return; | |
} | |
const { position, facing, velocity } = candidate; | |
const collisionReason = blocked(game, now, position, this.id); | |
if (collisionReason !== null) { | |
const backoff = Math.random() * PATHFINDING_BACKOFF; | |
console.warn(`Stopping path for ${this.id}, waiting for ${backoff}ms: ${collisionReason}`); | |
this.pathfinding.state = { | |
kind: 'waiting', | |
until: now + backoff, | |
}; | |
return; | |
} | |
// Update the player's location. | |
this.position = position; | |
this.facing = facing; | |
this.speed = velocity; | |
} | |
static join( | |
game: Game, | |
now: number, | |
name: string, | |
character: string, | |
description: string, | |
type: CharacterType, | |
tokenIdentifier?: string, | |
) { | |
if (tokenIdentifier) { | |
let numHumans = 0; | |
for (const player of game.world.players.values()) { | |
if (player.human) { | |
numHumans++; | |
} | |
if (player.human === tokenIdentifier) { | |
throw new Error(`You are already in this game!`); | |
} | |
} | |
if (numHumans >= MAX_HUMAN_PLAYERS) { | |
throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`); | |
} | |
} | |
let position; | |
for (let attempt = 0; attempt < 10; attempt++) { | |
const candidate = { | |
x: Math.floor(Math.random() * game.worldMap.width), | |
y: Math.floor(Math.random() * game.worldMap.height), | |
}; | |
if (blocked(game, now, candidate)) { | |
continue; | |
} | |
position = candidate; | |
break; | |
} | |
if (!position) { | |
throw new Error(`Failed to find a free position!`); | |
} | |
const facingOptions = [ | |
{ dx: 1, dy: 0 }, | |
{ dx: -1, dy: 0 }, | |
{ dx: 0, dy: 1 }, | |
{ dx: 0, dy: -1 }, | |
]; | |
const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)]; | |
if (!characters.find((c) => c.name === character)) { | |
throw new Error(`Invalid character: ${character}`); | |
} | |
const playerId = game.allocId('players'); | |
game.world.players.set( | |
playerId, | |
new Player({ | |
id: playerId, | |
human: tokenIdentifier, | |
lastInput: now, | |
position, | |
facing, | |
speed: 0, | |
}), | |
); | |
// add to duplicate players | |
game.world.playersInit.set( | |
playerId, | |
new Player({ | |
id: playerId, | |
human: tokenIdentifier, | |
lastInput: now, | |
position, | |
facing, | |
speed: 0, | |
}), | |
); | |
game.playerDescriptions.set( | |
playerId, | |
new PlayerDescription({ | |
playerId, | |
character, | |
description, | |
name, | |
type, | |
}), | |
); | |
game.descriptionsModified = true; | |
return playerId; | |
} | |
leave(game: Game, now: number) { | |
// Stop our conversation if we're leaving the game. | |
const conversation = [...game.world.conversations.values()].find((c) => | |
c.participants.has(this.id), | |
); | |
if (conversation) { | |
conversation.stop(game, now); | |
} | |
game.world.players.delete(this.id); | |
} | |
kill(game: Game, now: number) { | |
const playerId = this.id | |
console.log(`player ${ playerId } is killed`) | |
// first leave: | |
this.leave(game, now) | |
// if the player is npc, kill agent as well | |
const agent = [...game.world.agents.values()].find( | |
agent => agent.playerId === playerId | |
) | |
if (agent) { | |
agent.kill(game, now) | |
} | |
} | |
serialize(): SerializedPlayer { | |
const { id, human, pathfinding, activity, lastInput, position, facing, speed } = this; | |
return { | |
id, | |
human, | |
pathfinding, | |
activity, | |
lastInput, | |
position, | |
facing, | |
speed, | |
}; | |
} | |
} | |
export const playerInputs = { | |
join: inputHandler({ | |
args: { | |
name: v.string(), | |
character: v.string(), | |
description: v.string(), | |
tokenIdentifier: v.optional(v.string()), | |
type: CharacterTypeSchema | |
}, | |
handler: (game, now, args) => { | |
Player.join(game, now, args.name, args.character, args.description, args.type ,args.tokenIdentifier); | |
// Temporary role assignment for testing | |
game.assignRoles() | |
return null; | |
}, | |
}), | |
leave: inputHandler({ | |
args: { playerId }, | |
handler: (game, now, args) => { | |
const playerId = parseGameId('players', args.playerId); | |
const player = game.world.players.get(playerId); | |
if (!player) { | |
throw new Error(`Invalid player ID ${playerId}`); | |
} | |
player.leave(game, now); | |
return null; | |
}, | |
}), | |
moveTo: inputHandler({ | |
args: { | |
playerId, | |
destination: v.union(point, v.null()), | |
}, | |
handler: (game, now, args) => { | |
const playerId = parseGameId('players', args.playerId); | |
const player = game.world.players.get(playerId); | |
if (!player) { | |
throw new Error(`Invalid player ID ${playerId}`); | |
} | |
if (args.destination) { | |
movePlayer(game, now, player, args.destination); | |
} else { | |
stopPlayer(player); | |
} | |
return null; | |
}, | |
}), | |
gameVote: inputHandler({ | |
args: { | |
voter: playerId, | |
votedPlayerIds: v.array(playerId), | |
}, | |
handler: (game, now, args) => { | |
const voterId = parseGameId('players', args.voter); | |
const votedPlayerIds = args.votedPlayerIds.map((playerId) => parseGameId('players', playerId)); | |
gameVote(game, voterId, votedPlayerIds); | |
return null; | |
}, | |
}), | |
llmVote: inputHandler({ | |
args: { | |
voter: playerId, | |
votedPlayerIds: v.array(playerId), | |
}, | |
handler: (game, now, args) => { | |
const voterId = parseGameId('players', args.voter); | |
const votedPlayerIds = args.votedPlayerIds.map((playerId) => parseGameId('players', playerId)); | |
llmVote(game, voterId, votedPlayerIds); | |
return null; | |
}, | |
}), | |
}; | |