trucoshi 12.1.0 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ import { IPlayedCard, ITutorialScenario, TutorialScenarioId } from "../types";
2
+ import type { IRound } from "../truco/Round";
3
+ export declare const DEFAULT_TUTORIAL_SCENARIO_ID: TutorialScenarioId;
4
+ export declare const TUTORIAL_ROUND_RESULT_TOKEN = "{{roundResult}}";
5
+ export declare const TUTORIAL_PREVIOUS_ROUND_SCORE_TOKEN = "{{previousRoundScore}}";
6
+ export declare const renderTutorialRoundResult: (playedCards: IPlayedCard[]) => string | null;
7
+ export declare const renderTutorialPreviousRoundScore: (rounds: IRound[] | undefined) => string | null;
8
+ export declare const renderTutorialMessageText: (text: string, roundCards: IPlayedCard[] | undefined, previousRounds?: IRound[]) => string | null;
9
+ export declare const validateTutorialScenario: (scenario: ITutorialScenario) => ITutorialScenario;
10
+ export declare const TUTORIAL_SCENARIOS: Record<TutorialScenarioId, ITutorialScenario>;
11
+ export declare const getTutorialScenario: (id?: TutorialScenarioId | null | undefined) => ITutorialScenario;
@@ -0,0 +1,238 @@
1
+ import { CARDS, EAnswerCommand, EEnvidoAnswerCommand, EEnvidoCommand, EFlorCommand, EHandState, ESayCommand, ETrucoCommand, } from "../types";
2
+ import basicTrucoV1 from "./basic-truco-v1.json";
3
+ export const DEFAULT_TUTORIAL_SCENARIO_ID = "basic-truco-v1";
4
+ export const TUTORIAL_ROUND_RESULT_TOKEN = "{{roundResult}}";
5
+ export const TUTORIAL_PREVIOUS_ROUND_SCORE_TOKEN = "{{previousRoundScore}}";
6
+ const SUIT_NAMES = {
7
+ e: "espada",
8
+ b: "basto",
9
+ o: "oro",
10
+ c: "copa",
11
+ };
12
+ const CARD_NUMBER_NAMES = {
13
+ r: "12",
14
+ c: "11",
15
+ p: "10",
16
+ };
17
+ const getTutorialCardName = (card) => {
18
+ var _a, _b;
19
+ if (card === "1e") {
20
+ return "ancho de espada";
21
+ }
22
+ if (card === "1b") {
23
+ return "ancho de basto";
24
+ }
25
+ const suit = card.slice(-1);
26
+ const number = card.slice(0, -1);
27
+ const cardNumber = (_a = CARD_NUMBER_NAMES[number]) !== null && _a !== void 0 ? _a : number;
28
+ const suitName = (_b = SUIT_NAMES[suit]) !== null && _b !== void 0 ? _b : suit;
29
+ return `${cardNumber} de ${suitName}`;
30
+ };
31
+ export const renderTutorialRoundResult = (playedCards) => {
32
+ if (playedCards.length !== 2) {
33
+ return null;
34
+ }
35
+ const humanCard = playedCards.find((playedCard) => !playedCard.player.bot);
36
+ const botCard = playedCards.find((playedCard) => playedCard.player.bot);
37
+ if (!humanCard || !botCard) {
38
+ return null;
39
+ }
40
+ const humanValue = CARDS[humanCard.card];
41
+ const botValue = CARDS[botCard.card];
42
+ if (humanValue === undefined || botValue === undefined) {
43
+ return null;
44
+ }
45
+ const humanName = getTutorialCardName(humanCard.card);
46
+ const botName = getTutorialCardName(botCard.card);
47
+ if (humanValue > botValue) {
48
+ return `Ganaste la ronda: tu ${humanName} le gana al ${botName} del Profe.`;
49
+ }
50
+ if (botValue > humanValue) {
51
+ return `Gano el Profe: su ${botName} le gana a tu ${humanName}.`;
52
+ }
53
+ return `Parda: tu ${humanName} y el ${botName} tienen la misma fuerza.`;
54
+ };
55
+ export const renderTutorialPreviousRoundScore = (rounds) => {
56
+ const completedRounds = (rounds !== null && rounds !== void 0 ? rounds : []).filter((round) => round.cards.length >= 2);
57
+ if (!completedRounds.length) {
58
+ return null;
59
+ }
60
+ const score = completedRounds.reduce((current, round) => {
61
+ var _a;
62
+ if (round.tie) {
63
+ current.ties += 1;
64
+ return current;
65
+ }
66
+ if ((_a = round.winner) === null || _a === void 0 ? void 0 : _a.bot) {
67
+ current.bot += 1;
68
+ return current;
69
+ }
70
+ if (round.winner) {
71
+ current.human += 1;
72
+ }
73
+ return current;
74
+ }, { human: 0, bot: 0, ties: 0 });
75
+ if (score.human === 1 && score.bot === 1 && score.ties === 0) {
76
+ return "Van una ronda ganada cada uno.";
77
+ }
78
+ if (score.human === 1 && score.bot === 0 && score.ties === 0) {
79
+ return "Venis con una ronda ganada.";
80
+ }
81
+ if (score.bot === 1 && score.human === 0 && score.ties === 0) {
82
+ return "El Profe viene con una ronda ganada.";
83
+ }
84
+ if (score.ties === 1 && score.human === 0 && score.bot === 0) {
85
+ return "La primera fue parda.";
86
+ }
87
+ return "Las rondas anteriores dejaron la mano abierta.";
88
+ };
89
+ export const renderTutorialMessageText = (text, roundCards, previousRounds) => {
90
+ let renderedText = text;
91
+ if (renderedText.includes(TUTORIAL_ROUND_RESULT_TOKEN)) {
92
+ const roundResult = roundCards ? renderTutorialRoundResult(roundCards) : null;
93
+ if (!roundResult) {
94
+ return null;
95
+ }
96
+ renderedText = renderedText.split(TUTORIAL_ROUND_RESULT_TOKEN).join(roundResult);
97
+ }
98
+ if (renderedText.includes(TUTORIAL_PREVIOUS_ROUND_SCORE_TOKEN)) {
99
+ const previousRoundScore = renderTutorialPreviousRoundScore(previousRounds);
100
+ if (!previousRoundScore) {
101
+ return null;
102
+ }
103
+ renderedText = renderedText
104
+ .split(TUTORIAL_PREVIOUS_ROUND_SCORE_TOKEN)
105
+ .join(previousRoundScore);
106
+ }
107
+ return renderedText;
108
+ };
109
+ const MESSAGE_TRIGGERS = [
110
+ "hand_start",
111
+ "before_human_turn",
112
+ "after_human_action",
113
+ "before_bot_action",
114
+ "after_bot_action",
115
+ "hand_end",
116
+ ];
117
+ const COMMANDS = new Set([
118
+ ...Object.values(ESayCommand),
119
+ ...Object.values(ETrucoCommand),
120
+ ...Object.values(EAnswerCommand),
121
+ ...Object.values(EEnvidoCommand),
122
+ ...Object.values(EEnvidoAnswerCommand),
123
+ ...Object.values(EFlorCommand),
124
+ ]);
125
+ const HAND_STATES = new Set(Object.values(EHandState));
126
+ const isCard = (value) => typeof value === "string" && value in CARDS;
127
+ const isCommand = (value) => typeof value === "string" && COMMANDS.has(value);
128
+ const assertValidActionValue = (value, context) => {
129
+ if (typeof value === "number") {
130
+ return;
131
+ }
132
+ if (isCard(value) || isCommand(value)) {
133
+ return;
134
+ }
135
+ throw new Error(`${context} has invalid action value: ${String(value)}`);
136
+ };
137
+ const validateCardList = (value, context) => {
138
+ if (!Array.isArray(value) || value.some((card) => !isCard(card))) {
139
+ throw new Error(`${context} must be a list of valid card ids`);
140
+ }
141
+ };
142
+ const validateMessage = (message, context) => {
143
+ if (!MESSAGE_TRIGGERS.includes(message.trigger)) {
144
+ throw new Error(`${context} has invalid trigger: ${message.trigger}`);
145
+ }
146
+ if (typeof message.text !== "string" || !message.text.trim()) {
147
+ throw new Error(`${context} must contain text`);
148
+ }
149
+ if (message.text.length > 155) {
150
+ throw new Error(`${context} text is longer than 155 characters`);
151
+ }
152
+ if (message.state && !HAND_STATES.has(message.state)) {
153
+ throw new Error(`${context} has invalid state: ${message.state}`);
154
+ }
155
+ if (message.actionValue !== undefined) {
156
+ assertValidActionValue(message.actionValue, context);
157
+ }
158
+ if (message.roundComplete !== undefined && typeof message.roundComplete !== "boolean") {
159
+ throw new Error(`${context} has invalid roundComplete`);
160
+ }
161
+ if (message.roundCardCount !== undefined &&
162
+ (!Number.isInteger(message.roundCardCount) || message.roundCardCount < 0)) {
163
+ throw new Error(`${context} has invalid roundCardCount`);
164
+ }
165
+ if (message.requiresHandCards !== undefined) {
166
+ validateCardList(message.requiresHandCards, `${context} requiresHandCards`);
167
+ }
168
+ if (message.requiresRoundCards !== undefined) {
169
+ validateCardList(message.requiresRoundCards, `${context} requiresRoundCards`);
170
+ }
171
+ };
172
+ const validateBotAction = (botAction, context) => {
173
+ var _a;
174
+ if (botAction.trigger !== "before_bot_action") {
175
+ throw new Error(`${context} has invalid trigger: ${botAction.trigger}`);
176
+ }
177
+ if (botAction.state && !HAND_STATES.has(botAction.state)) {
178
+ throw new Error(`${context} has invalid state: ${botAction.state}`);
179
+ }
180
+ if (!["card", "command"].includes((_a = botAction.action) === null || _a === void 0 ? void 0 : _a.type)) {
181
+ throw new Error(`${context} has invalid action type`);
182
+ }
183
+ if (botAction.action.type === "card" && !isCard(botAction.action.value)) {
184
+ throw new Error(`${context} card action must use a valid card id`);
185
+ }
186
+ if (botAction.action.type === "command" && !isCommand(botAction.action.value) && typeof botAction.action.value !== "number") {
187
+ throw new Error(`${context} command action must use a valid command`);
188
+ }
189
+ };
190
+ const validateHand = (hand, handIdx) => {
191
+ if (typeof hand.goal !== "string" || !hand.goal.trim()) {
192
+ throw new Error(`Tutorial hand ${handIdx} must have a goal`);
193
+ }
194
+ const usedCards = new Set();
195
+ for (const [playerIdx, cards] of Object.entries(hand.cardsByPlayerIdx || {})) {
196
+ if (!Number.isInteger(Number(playerIdx))) {
197
+ throw new Error(`Tutorial hand ${handIdx} has invalid player index: ${playerIdx}`);
198
+ }
199
+ if (!Array.isArray(cards) || cards.length !== 3) {
200
+ throw new Error(`Tutorial hand ${handIdx} player ${playerIdx} must have exactly 3 cards`);
201
+ }
202
+ for (const card of cards) {
203
+ if (!isCard(card)) {
204
+ throw new Error(`Tutorial hand ${handIdx} has invalid card: ${String(card)}`);
205
+ }
206
+ if (usedCards.has(card)) {
207
+ throw new Error(`Tutorial hand ${handIdx} has duplicate card: ${card}`);
208
+ }
209
+ usedCards.add(card);
210
+ }
211
+ }
212
+ hand.messages.forEach((message, index) => validateMessage(message, `Tutorial hand ${handIdx} message ${index}`));
213
+ hand.botActions.forEach((botAction, index) => validateBotAction(botAction, `Tutorial hand ${handIdx} bot action ${index}`));
214
+ };
215
+ export const validateTutorialScenario = (scenario) => {
216
+ if (!scenario.id || !scenario.title || !scenario.botProfile || !scenario.botName) {
217
+ throw new Error("Tutorial scenario must define id, title, botProfile, and botName");
218
+ }
219
+ if (scenario.options.maxPlayers !== 2) {
220
+ throw new Error("Tutorial v1 scenarios must be 1v1");
221
+ }
222
+ if (!Array.isArray(scenario.hands) || !scenario.hands.length) {
223
+ throw new Error(`Tutorial scenario ${scenario.id} must include hands`);
224
+ }
225
+ scenario.hands.forEach((hand, index) => validateHand(hand, index + 1));
226
+ return scenario;
227
+ };
228
+ export const TUTORIAL_SCENARIOS = {
229
+ [basicTrucoV1.id]: validateTutorialScenario(basicTrucoV1),
230
+ };
231
+ export const getTutorialScenario = (id = DEFAULT_TUTORIAL_SCENARIO_ID) => {
232
+ const scenarioId = id !== null && id !== void 0 ? id : DEFAULT_TUTORIAL_SCENARIO_ID;
233
+ const scenario = TUTORIAL_SCENARIOS[scenarioId];
234
+ if (!scenario) {
235
+ throw new Error(`Tutorial scenario not found: ${scenarioId}`);
236
+ }
237
+ return scenario;
238
+ };
package/dist/types.d.ts CHANGED
@@ -46,6 +46,53 @@ export interface ILobbyOptions {
46
46
  abandonTime: number;
47
47
  satsPerPlayer: number;
48
48
  }
49
+ export type TutorialScenarioId = "basic-truco-v1" | string;
50
+ export type ITutorialMessageTrigger = "hand_start" | "before_human_turn" | "after_human_action" | "before_bot_action" | "after_bot_action" | "hand_end";
51
+ export interface ITutorialMessage {
52
+ trigger: ITutorialMessageTrigger;
53
+ text: string;
54
+ roundIdx?: number;
55
+ state?: EHandState;
56
+ playerIdx?: number;
57
+ actionValue?: ECommand | ICard | number;
58
+ roundComplete?: boolean;
59
+ roundCardCount?: number;
60
+ requiresHandCards?: ICard[];
61
+ requiresRoundCards?: ICard[];
62
+ }
63
+ export interface ITutorialBotAction {
64
+ trigger: Extract<ITutorialMessageTrigger, "before_bot_action">;
65
+ roundIdx?: number;
66
+ state?: EHandState;
67
+ playerIdx?: number;
68
+ action: {
69
+ type: "card" | "command";
70
+ value: ICard | ECommand | number;
71
+ };
72
+ }
73
+ export interface ITutorialHand {
74
+ goal: string;
75
+ cardsByPlayerIdx: Record<string, [ICard, ICard, ICard]>;
76
+ messages: ITutorialMessage[];
77
+ botActions: ITutorialBotAction[];
78
+ }
79
+ export interface ITutorialScenario {
80
+ id: TutorialScenarioId;
81
+ title: string;
82
+ botProfile: BotProfile;
83
+ botName: string;
84
+ options: Pick<ILobbyOptions, "maxPlayers" | "matchPoint" | "flor" | "satsPerPlayer"> & Partial<Omit<ILobbyOptions, "maxPlayers" | "matchPoint" | "flor" | "satsPerPlayer">>;
85
+ hands: ITutorialHand[];
86
+ }
87
+ export interface ITutorialRuntime {
88
+ scenario: ITutorialScenario;
89
+ sentMessageKeys: Set<string>;
90
+ executedActionKeys: Set<string>;
91
+ messageQueue: Promise<void>;
92
+ hasQueuedMessage: boolean;
93
+ inputLocked: boolean;
94
+ messageGeneration: number;
95
+ }
49
96
  export interface IJoinQueueOptions {
50
97
  maxPlayers: 0 | 2 | 4 | 6;
51
98
  allowBots: boolean;
@@ -53,6 +100,7 @@ export interface IJoinQueueOptions {
53
100
  export interface IQueueStatus {
54
101
  requestId: string;
55
102
  maxPlayers: 0 | 2 | 4 | 6;
103
+ allowBots: boolean;
56
104
  queuedPlayers: number;
57
105
  requiredPlayers: number;
58
106
  position: number;
@@ -151,6 +199,12 @@ export interface IPublicMatch {
151
199
  lastCard?: ICard | null;
152
200
  lastCommand?: ECommand | number | null;
153
201
  awardedSatsPerPlayer?: number;
202
+ tutorial?: {
203
+ id: TutorialScenarioId;
204
+ title: string;
205
+ botKey: string;
206
+ inputLocked?: boolean;
207
+ };
154
208
  }
155
209
  export interface IPublicMatchStats {
156
210
  spectators: number;
@@ -164,6 +218,7 @@ export interface IPublicMatchInfo {
164
218
  winnerTeamIdx: 0 | 1 | undefined;
165
219
  createdFromQueue: boolean;
166
220
  queueOptions?: IJoinQueueOptions;
221
+ isTutorial?: boolean;
167
222
  }
168
223
  export type IPublicChatRoom = Pick<IChatRoom, "id" | "messages">;
169
224
  export interface IChatMessage {
@@ -178,6 +233,8 @@ export interface IChatMessage {
178
233
  hidden?: boolean;
179
234
  command?: boolean;
180
235
  card?: boolean;
236
+ tutorial?: boolean;
237
+ tutorialContext?: string;
181
238
  content: string;
182
239
  sound: string | boolean;
183
240
  }
@@ -190,6 +247,7 @@ export interface IChatRoom {
190
247
  send(user: IChatMessage["user"], message: string, sound?: string | boolean): void;
191
248
  card(user: IChatMessage["user"], card: ICard, sound?: string | boolean): void;
192
249
  command(team: 0 | 1, command: ECommand | number, sound?: string | boolean): void;
250
+ tutorial(user: IChatMessage["user"], message: string, sound?: string | boolean, tutorialContext?: string): void;
193
251
  system(message: string, sound?: string | boolean): void;
194
252
  sound(sound: string, toTeamIdx?: "0" | "1", fromUser?: IChatMessage["user"]): void;
195
253
  emit(message?: IChatMessage, teamIdxs?: string): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trucoshi",
3
- "version": "12.1.0",
3
+ "version": "12.2.0",
4
4
  "description": "Truco Game Server",
5
5
  "main": "dist/types.js",
6
6
  "license": "GPL-3.0",
@@ -65,6 +65,7 @@
65
65
  "files": [
66
66
  "dist/lib/*",
67
67
  "dist/cosmetics/*",
68
+ "dist/tutorials/*",
68
69
  "dist/types.js",
69
70
  "dist/types.d.ts",
70
71
  "dist/events.js",
@@ -36,11 +36,11 @@ exports.Prisma = Prisma
36
36
  exports.$Enums = {}
37
37
 
38
38
  /**
39
- * Prisma Client JS version: 6.19.2
39
+ * Prisma Client JS version: 6.19.3
40
40
  * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
41
41
  */
42
42
  Prisma.prismaVersion = {
43
- client: "6.19.2",
43
+ client: "6.19.3",
44
44
  engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
45
45
  }
46
46
 
@@ -330,7 +330,7 @@ const config = {
330
330
  "schemaEnvPath": "../../.env"
331
331
  },
332
332
  "relativePath": "..",
333
- "clientVersion": "6.19.2",
333
+ "clientVersion": "6.19.3",
334
334
  "engineVersion": "c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
335
335
  "datasourceNames": [
336
336
  "db"
@@ -21,11 +21,11 @@ exports.Prisma = Prisma
21
21
  exports.$Enums = {}
22
22
 
23
23
  /**
24
- * Prisma Client JS version: 6.19.2
24
+ * Prisma Client JS version: 6.19.3
25
25
  * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
26
26
  */
27
27
  Prisma.prismaVersion = {
28
- client: "6.19.2",
28
+ client: "6.19.3",
29
29
  engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
30
30
  }
31
31
 
@@ -418,7 +418,7 @@ export namespace Prisma {
418
418
  export import Exact = $Public.Exact
419
419
 
420
420
  /**
421
- * Prisma Client JS version: 6.19.2
421
+ * Prisma Client JS version: 6.19.3
422
422
  * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
423
423
  */
424
424
  export type PrismaVersion = {
@@ -36,11 +36,11 @@ exports.Prisma = Prisma
36
36
  exports.$Enums = {}
37
37
 
38
38
  /**
39
- * Prisma Client JS version: 6.19.2
39
+ * Prisma Client JS version: 6.19.3
40
40
  * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
41
41
  */
42
42
  Prisma.prismaVersion = {
43
- client: "6.19.2",
43
+ client: "6.19.3",
44
44
  engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
45
45
  }
46
46
 
@@ -331,7 +331,7 @@ const config = {
331
331
  "schemaEnvPath": "../../.env"
332
332
  },
333
333
  "relativePath": "..",
334
- "clientVersion": "6.19.2",
334
+ "clientVersion": "6.19.3",
335
335
  "engineVersion": "c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
336
336
  "datasourceNames": [
337
337
  "db"
@@ -151,7 +151,7 @@
151
151
  },
152
152
  "./*": "./*"
153
153
  },
154
- "version": "6.19.2",
154
+ "version": "6.19.3",
155
155
  "sideEffects": false,
156
156
  "imports": {
157
157
  "#wasm-engine-loader": {
@@ -36,11 +36,11 @@ exports.Prisma = Prisma
36
36
  exports.$Enums = {}
37
37
 
38
38
  /**
39
- * Prisma Client JS version: 6.19.2
39
+ * Prisma Client JS version: 6.19.3
40
40
  * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
41
41
  */
42
42
  Prisma.prismaVersion = {
43
- client: "6.19.2",
43
+ client: "6.19.3",
44
44
  engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
45
45
  }
46
46
 
@@ -330,7 +330,7 @@ const config = {
330
330
  "schemaEnvPath": "../../.env"
331
331
  },
332
332
  "relativePath": "..",
333
- "clientVersion": "6.19.2",
333
+ "clientVersion": "6.19.3",
334
334
  "engineVersion": "c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
335
335
  "datasourceNames": [
336
336
  "db"