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
4 changes: 4 additions & 0 deletions src/i18n/languages/english.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,13 @@ export default {
BUY: 'Buy!',
RESERVE: 'Reserve!',
BUY_CARD: 'Buy {{card}}!',
CHOOSE: 'Choose!',
},
INVALID_POINTS_CAP: 'The point cap must be between {{minCap}} and {{maxCap}}. {{cap}} is invalid.',
INVALID_CARD: '{{card}} is not a valid card.',
CARD_NOT_ACCESSIBLE: 'Cannot access {{card}} for the desired action.',
DISCARD_TOKENS_REQUIRED: 'You need to discard tokens!',
CLAIM_TRAINER_REQUIRED: 'You need to select a trainer!',
CARD_NOT_AVAILABLE_RESERVE: '{{card}} is not available to reserve.',
CARD_NOT_AVAILABLE_BUY: '{{card}} is not available to buy.',
CANNOT_BUY_OR_RESERVE: 'You can neither buy nor reserve {{card}}.',
Expand Down Expand Up @@ -209,6 +211,8 @@ export default {
ONE_EACH_TYPE: 'You can only take 1 token from each of the 3 types!{{info}}',
TOO_MANY_TOKENS_MESSAGE:
'You have too many tokens! The maximum you can have at a time is {{max}}; please discard at least {{discard}}.',
CLAIM_TRAINER_MESSAGE:
'You have attracted the attention of multiple trainers! Please choose one of them to join you this turn. You may accept other trainers on future turns.',
},
},

Expand Down
6 changes: 5 additions & 1 deletion src/ps/games/splendor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ export enum VIEW_ACTION_TYPE {
CLICK_DECK = 'deck',
CLICK_RESERVE = 'payback',
CLICK_TOKENS = 'tokens',
TOO_MANY_TOKENS = 'discard',
GAME_END = 'end',
}

export enum POST_TURN_ACTIONS {
TOO_MANY_TOKENS = 'discard',
CLAIM_TRAINER = 'noble',
}

export const MIN_POINTS_TO_WIN = 8;
export const MAX_POINTS_TO_WIN = 21;
export const DEFAULT_POINTS_TO_WIN = 15;
Expand Down
86 changes: 69 additions & 17 deletions src/ps/games/splendor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MAX_RESERVE_COUNT,
MAX_TOKEN_COUNT,
MIN_POINTS_TO_WIN,
POST_TURN_ACTIONS,
TOKEN_TYPE,
TokenTypes,
VIEW_ACTION_TYPE,
Expand Down Expand Up @@ -176,12 +177,14 @@ export class Splendor extends BaseGame<State> {
const playerData = this.state.playerData[player.turn];
const [action, actionCtx] = ctx.lazySplit(' ', 1);

if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS && action !== VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
if (this.state.actionState.action === POST_TURN_ACTIONS.TOO_MANY_TOKENS && action !== POST_TURN_ACTIONS.TOO_MANY_TOKENS)
throw new ChatError(this.$T('GAME.SPLENDOR.DISCARD_TOKENS_REQUIRED'));
if (this.state.actionState.action === POST_TURN_ACTIONS.CLAIM_TRAINER && action !== POST_TURN_ACTIONS.CLAIM_TRAINER)
throw new ChatError(this.$T('GAME.SPLENDOR.CLAIM_TRAINER_REQUIRED'));

let logEntry: Log;
// VIEW_ACTION_TYPES update the user's state while staying on the same turn. Use 'return'.
// The exception to this is TOO_MANY_TOKENS, which is deferred from ACTIONS and uses 'break'.
// POST_TURN_ACTIONS happen after actual actions. They are deferred from actions. Use 'break'.
// ACTIONS are actual actions, and will end the turn and stuff if valid. Use 'break'.
switch (action) {
case VIEW_ACTION_TYPE.CLICK_TOKENS: {
Expand Down Expand Up @@ -235,8 +238,8 @@ export class Splendor extends BaseGame<State> {
return;
}

case VIEW_ACTION_TYPE.TOO_MANY_TOKENS: {
if (this.state.actionState.action !== VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
case POST_TURN_ACTIONS.TOO_MANY_TOKENS: {
if (this.state.actionState.action !== POST_TURN_ACTIONS.TOO_MANY_TOKENS)
throw new ChatError(this.$T('GAME.SPLENDOR.NO_DISCARD_NEEDED'));
const toDiscard = this.state.actionState.discard;
const tokens = this.parseTokens(actionCtx, true);
Expand All @@ -246,7 +249,21 @@ export class Splendor extends BaseGame<State> {
if (!this.canAfford(tokens, playerData.tokens, null, false)) throw new ChatError(this.$T('GAME.SPLENDOR.CANNOT_DISCARD'));

this.spendTokens(tokens, playerData);
logEntry = { turn: player.turn, time: new Date(), action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS, ctx: { discard: tokens } };
logEntry = { turn: player.turn, time: new Date(), action: POST_TURN_ACTIONS.TOO_MANY_TOKENS, ctx: { discard: tokens } };
break;
}

case POST_TURN_ACTIONS.CLAIM_TRAINER: {
const trainer = metadata.trainers[actionCtx];

this.state.board.trainers.remove(trainer);
playerData.trainers.push(trainer);
logEntry = {
turn: player.turn,
time: new Date(),
action: POST_TURN_ACTIONS.CLAIM_TRAINER,
ctx: { trainerId: trainer.id },
};
break;
}

Expand Down Expand Up @@ -343,7 +360,8 @@ export class Splendor extends BaseGame<State> {
if (!validateTokens.success) throw new ChatError(validateTokens.error);
this.receiveTokens(tokens, playerData);

logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.DRAW, ctx: { tokens } };
const totalTokens = Object.values(playerData.tokens).sum();
logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.DRAW, ctx: { tokens, totalTokens } };
break;
}

Expand All @@ -357,24 +375,14 @@ export class Splendor extends BaseGame<State> {
}
}

// TODO: Add a UI for one-at-a-time
const newTrainers = this.state.board.trainers.filter(trainer => this.canAfford(trainer.types, {}, playerData.cards));
this.state.board.trainers.remove(...newTrainers);
playerData.trainers.push(...newTrainers);
if (logEntry.ctx) logEntry.ctx.trainers = newTrainers.map(trainer => trainer.id);
this.chatLog(logEntry);

playerData.points = playerData.cards.map(card => card.points).sum() + playerData.trainers.map(trainer => trainer.points).sum();

this.state.actionState = { action: VIEW_ACTION_TYPE.NONE };

if (this.gameCanEnd()) return this.end();
else if (Object.values(playerData.tokens).sum() > MAX_TOKEN_COUNT) {
const count = Object.values(playerData.tokens).sum();
this.state.actionState = { action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS, discard: count - MAX_TOKEN_COUNT };
this.update(user.id);
this.backup();
} else this.endTurn();
else this.handlePostTurn(action, playerData, user, player, logEntry);
}

canAfford(
Expand Down Expand Up @@ -487,6 +495,50 @@ export class Splendor extends BaseGame<State> {
return { success: true, data: null };
}

handlePostTurn(action: POST_TURN_ACTIONS | ACTIONS, playerData: PlayerData, user: User, player: Player, logEntry: Log): void {
if (Object.values(playerData.tokens).sum() > MAX_TOKEN_COUNT) {
const count = Object.values(playerData.tokens).sum();
this.state.actionState = { action: POST_TURN_ACTIONS.TOO_MANY_TOKENS, discard: count - MAX_TOKEN_COUNT };
this.update(user.id);
this.backup();
return;
}

if (action === POST_TURN_ACTIONS.CLAIM_TRAINER) {
this.endTurn();
return;
}

const affordableTrainers = this.state.board.trainers.filter(trainer => this.canAfford(trainer.types, {}, playerData.cards));

if (affordableTrainers.length === 0) {
this.endTurn();
return;
}

if (affordableTrainers.length > 1) {
this.state.actionState = { action: POST_TURN_ACTIONS.CLAIM_TRAINER, canAfford: affordableTrainers };
this.update(user.id);
this.backup();
return;
}

this.state.board.trainers.remove(affordableTrainers[0]);
playerData.trainers.push(affordableTrainers[0]);
logEntry = {
turn: player.turn,
time: new Date(),
action: POST_TURN_ACTIONS.CLAIM_TRAINER,
ctx: { trainerId: affordableTrainers[0].id },
};

playerData.points += affordableTrainers[0].points;
this.state.actionState = { action: VIEW_ACTION_TYPE.NONE };
this.update(user.id);
this.chatLog(logEntry);
this.endTurn();
}

onEnd(type?: EndType): TranslatedText {
if (type) {
this.winCtx = { type };
Expand Down
18 changes: 11 additions & 7 deletions src/ps/games/splendor/logs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ACTIONS, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
import type { ACTIONS, POST_TURN_ACTIONS } from '@/ps/games/splendor/constants';
import type { TokenCount, Turn } from '@/ps/games/splendor/types';
import type { BaseLog } from '@/ps/games/types';
import type { Satisfies, SerializedInstance } from '@/types/common';
Expand All @@ -11,23 +11,27 @@ export type Log = Satisfies<
} & (
| {
action: ACTIONS.BUY;
ctx: { id: string; cost: Partial<TokenCount>; trainers?: string[] };
ctx: { id: string; cost: Partial<TokenCount> };
}
| {
action: ACTIONS.BUY_RESERVE;
ctx: { id: string; cost: Partial<TokenCount>; trainers?: string[] };
ctx: { id: string; cost: Partial<TokenCount> };
}
| {
action: ACTIONS.RESERVE;
ctx: { id: string; gotDragon?: boolean; deck: number | null; trainers?: string[] };
ctx: { id: string; gotDragon?: boolean; deck: number | null };
}
| {
action: ACTIONS.DRAW;
ctx: { tokens: Partial<TokenCount>; trainers?: string[] };
ctx: { tokens: Partial<TokenCount>; totalTokens: number };
}
| {
action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS;
ctx: { discard: Partial<TokenCount>; trainers?: string[] };
action: POST_TURN_ACTIONS.TOO_MANY_TOKENS;
ctx: { discard: Partial<TokenCount> };
}
| {
action: POST_TURN_ACTIONS.CLAIM_TRAINER;
ctx: { trainerId: string };
}
| { action: 'pass'; ctx: null }
)
Expand Down
90 changes: 74 additions & 16 deletions src/ps/games/splendor/render.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { LogEntry } from '@/ps/games/render';
import { ACTIONS, AllTokenTypes, MAX_TOKEN_COUNT, TOKEN_TYPE, TokenTypes, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
import {
ACTIONS,
AllTokenTypes,
MAX_TOKEN_COUNT,
POST_TURN_ACTIONS,
TOKEN_TYPE,
TokenTypes,
VIEW_ACTION_TYPE,
} from '@/ps/games/splendor/constants';
import metadata from '@/ps/games/splendor/metadata.json';
import { isAprilFoolsActive } from '@/ps/specialEvents';
import { Username } from '@/utils/components';
import { Button, Form } from '@/utils/components/ps';
import { Logger } from '@/utils/logger';

import type { TranslatedText, TranslationFn } from '@/i18n/types';
import type { Splendor } from '@/ps/games/splendor/index';
import { Splendor } from '@/ps/games/splendor/index';
import type { Log } from '@/ps/games/splendor/logs';
import type { Board, Card, PlayerData, RenderCtx, TokenCount, Trainer, ViewType } from '@/ps/games/splendor/types';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
Expand All @@ -34,18 +42,10 @@ const TOKEN_COLOURS: Record<TOKEN_TYPE, string> = {
};

export function renderLog(logEntry: Log, game: Splendor): [ReactElement, { name: string }] {
const Wrapper = ({ children }: { children: ReactNode }): ReactElement => (
<LogEntry game={game}>
{children}
{logEntry.ctx?.trainers?.length
? ` ${logEntry.ctx.trainers.map(id => metadata.trainers[id].name).list(game.$T)} joined them!`
: null}
</LogEntry>
);
const Wrapper = ({ children }: { children: ReactNode }): ReactElement => <LogEntry game={game}>{children}</LogEntry>;

const playerName = game.players[logEntry.turn]?.name;
const opts = { name: `${game.id}-chatlog` };

switch (logEntry.action) {
case ACTIONS.BUY:
case ACTIONS.BUY_RESERVE: {
Expand All @@ -66,8 +66,16 @@ export function renderLog(logEntry: Log, game: Splendor): [ReactElement, { name:
opts,
];
}
case POST_TURN_ACTIONS.CLAIM_TRAINER: {
return [
<Wrapper>
{metadata.trainers[logEntry.ctx.trainerId].name} joined {<Username name={playerName} clickable />}!
</Wrapper>,
opts,
];
}
case ACTIONS.DRAW:
case VIEW_ACTION_TYPE.TOO_MANY_TOKENS: {
case POST_TURN_ACTIONS.TOO_MANY_TOKENS: {
const tokens = logEntry.action === ACTIONS.DRAW ? logEntry.ctx.tokens : logEntry.ctx.discard;
return [
<Wrapper>
Expand All @@ -79,7 +87,16 @@ export function renderLog(logEntry: Log, game: Splendor): [ReactElement, { name:
<TypeTokenCount type={type} count={count} />
))}
</span>
.
{logEntry.action === ACTIONS.DRAW && logEntry.ctx.totalTokens > 10 ? (
<>
<span style={{ opacity: 0.45, paddingTop: '0.5em', fontSize: '0.9em' }}>
<br />
{`They have ${logEntry.ctx.totalTokens} tokens and must discard ${logEntry.ctx.totalTokens - MAX_TOKEN_COUNT}`}.
</span>
</>
) : (
'.'
)}
</Wrapper>,
opts,
];
Expand Down Expand Up @@ -529,6 +546,37 @@ function ReservedCardInput({
);
}

function ChooseTrainerInput({
onClick,
affordableTrainers,
label,
}: {
onClick: string;
affordableTrainers: Trainer[];
label: string;
}): ReactElement {
return (
<Form
value={`${onClick} {selectedTrainer}`}
style={{ border: '1px solid', display: 'inline-block', padding: 12, borderRadius: 12, textAlign: 'left' }}
>
{affordableTrainers.map(trainer => (
<>
<input style={{ zoom: '180%' }} type="radio" value={trainer.id} name="selectedTrainer" id={trainer.id} required />
<label htmlFor={trainer.id}>
<span style={{ zoom: '67%', display: 'inline-block', transform: 'translateY(1em)' }}>
<TrainerCard data={trainer} />
</span>
<span style={{ marginTop: '-1 em' }}>{trainer.name}</span>
</label>
<br />
</>
))}
<button style={{ display: 'block', margin: '1em auto' }}>{label}</button>
</Form>
);
}

export function BaseBoard({
board,
view,
Expand Down Expand Up @@ -693,7 +741,7 @@ export function ActivePlayer({
data={card}
onClick={
action.active &&
action.action !== VIEW_ACTION_TYPE.TOO_MANY_TOKENS &&
action.action !== POST_TURN_ACTIONS.TOO_MANY_TOKENS &&
!(action.action === VIEW_ACTION_TYPE.CLICK_RESERVE && card.id === action.id)
? `${onClick} ! ${VIEW_ACTION_TYPE.CLICK_RESERVE}`
: undefined
Expand All @@ -715,14 +763,24 @@ export function ActivePlayer({
<div>{`You can't afford ${metadata.pokemon[action.id].name}...`}</div>
)
) : null}
{action.active && action.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS ? (
{action.active && action.action === POST_TURN_ACTIONS.TOO_MANY_TOKENS ? (
<div style={{ color: 'white' }}>
<p>{$T('GAME.SPLENDOR.TOO_MANY_TOKENS_MESSAGE', { max: MAX_TOKEN_COUNT, discard: action.discard })}</p>
<TokenInput
allowDragon
preset={null}
label={$T('GAME.LABELS.DISCARD')}
onClick={`${onClick} ! ${VIEW_ACTION_TYPE.TOO_MANY_TOKENS}`}
onClick={`${onClick} ! ${POST_TURN_ACTIONS.TOO_MANY_TOKENS}`}
/>
</div>
) : null}
{action.active && action.action === POST_TURN_ACTIONS.CLAIM_TRAINER ? (
<div style={{ color: 'white', width: '80%' }}>
<p>{$T('GAME.SPLENDOR.CLAIM_TRAINER_MESSAGE')}</p>
<ChooseTrainerInput
affordableTrainers={action.canAfford}
onClick={`${onClick} ! ${POST_TURN_ACTIONS.CLAIM_TRAINER}`}
label={$T('GAME.SPLENDOR.LABELS.CHOOSE')}
/>
</div>
) : null}
Expand Down
5 changes: 3 additions & 2 deletions src/ps/games/splendor/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TranslationFn } from '@/i18n/types';
import type { TOKEN_TYPE, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';
import type { POST_TURN_ACTIONS, TOKEN_TYPE, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants';

export type Turn = string;

Expand Down Expand Up @@ -66,7 +66,8 @@ export type ActionState =
))
| { action: VIEW_ACTION_TYPE.CLICK_DECK; tier: 1 | 2 | 3 }
| { action: VIEW_ACTION_TYPE.CLICK_RESERVE; id: string; preset: TokenCount | null }
| { action: VIEW_ACTION_TYPE.TOO_MANY_TOKENS; discard: number };
| { action: POST_TURN_ACTIONS.TOO_MANY_TOKENS; discard: number }
| { action: POST_TURN_ACTIONS.CLAIM_TRAINER; canAfford: Trainer[] };

export type ViewType =
| {
Expand Down
Loading