commit fff2df2bd2570c7ecc4f7b8f63c2087fe4118bf3
parent 894b693dbe3c10a573b26f215ea7b01386dd1fc6
Author: Francisco Armando Rosmino <franciscorosmino@rosmino.com.ar>
Date: Sat, 3 Oct 2026 12:35:01 -0300
Jugabilidad: confirmar el ladron, trueques explicitos, puertos visibles
Lo que salio de la partida XCFWRQ (Pancho y felox):
- El ladron se confirma antes de robar: hexagono -> (victima) -> 'Si, mover y
robarle a X' / 'Elegir otro lugar'; Esc vuelve al tablero. Un toque de mas
no se podia deshacer porque el robo es al azar. Solo en ctrl (TUI, GUI, web
y PicoCalc), sin cambios de protocolo. Test t_ctrl_robber.
- Trueques: 'ofrece: da 2 madera, pide 1 trigo' en el registro, 'te da ...,
te pide ...' al responder y en las contraofertas (la flecha no decia quien
daba que).
- Puertos: el disco va perpendicular a la costa, del lado del agua (desde el
centro del tablero caia sobre un vertice o adentro de la casilla) y se
dibuja encima de rutas y pueblos; los muelles quedan abajo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 104 insertions(+), 19 deletions(-)
diff --git a/catan/core/ctrl.c b/catan/core/ctrl.c
@@ -2,6 +2,8 @@
#include <string.h>
#include "ctrl.h"
+static void robber_confirm(Ctrl *c, int hex, int victim);
+
/* ------------------------------------------------------------ texto simple */
typedef struct { char *b; int n, pos; } Sb;
@@ -278,7 +280,8 @@ static void build_actions(Ctrl *c)
char t[48];
Sb b = { t, sizeof t, 0 };
t[0] = 0;
- sb_s(&b, c->names[s]); sb_s(&b, ": "); sb_res(&b, g->offer.cget[s]); sb_s(&b, " → "); sb_res(&b, g->offer.cgive[s]);
+ sb_s(&b, c->names[s]); sb_s(&b, ": "); sb_s(&b, tr(S_GIVES)); sb_s(&b, " "); sb_res(&b, g->offer.cgive[s]);
+ sb_s(&b, ", "); sb_s(&b, tr(S_WANTS)); sb_s(&b, " "); sb_res(&b, g->offer.cget[s]);
add_action(c, A_CONFIRM, s, game_can_afford(g, p, g->offer.cget[s]), (char)('5' + s), t, 0);
}
add_action(c, A_CANCEL, 0, true, 'x', tr(S_A_CANCEL), 0);
@@ -573,8 +576,8 @@ static bool commit_target(Ctrl *c, int id, Move *out)
m.a = (int16_t)id;
m.b = -1;
m.c = -1;
- if (!robber_victims(c, id, v)) return finish(c, out, m);
- if (v[1] < 0) { m.b = v[0]; return finish(c, out, m); }
+ if (!robber_victims(c, id, v)) { robber_confirm(c, id, -1); return false; }
+ if (v[1] < 0) { robber_confirm(c, id, v[0]); return false; }
c->pending_hex = id;
menu_begin(c, I_VICTIM, S_P_VICTIM);
for (int i = 0; i < MAXP && v[i] >= 0; i++)
@@ -588,6 +591,18 @@ static bool commit_target(Ctrl *c, int id, Move *out)
static void offer_to_menu(Ctrl *c);
static Move offer_move(const Ctrl *c, int mask);
+/* Antes de que el ladron robe se confirma (un toque de mas no puede deshacerse despues:
+ * el robo es al azar). Esc o "elegir otro lugar" vuelven a elegir el hexagono. */
+static void robber_confirm(Ctrl *c, int hex, int victim)
+{
+ c->pending_hex = hex;
+ c->pending_victim = victim;
+ menu_begin(c, I_ROBBER_OK, S_P_ROBBER_OK);
+ if (victim >= 0) menu_add(c, 1, 0, true, 'e', tr(S_ROBBER_OK), c->names[victim]);
+ else menu_add(c, 1, 0, true, 'e', tr(S_ROBBER_OK_NO), 0);
+ menu_add(c, 0, 0, true, 'v', tr(S_ROBBER_ELSE), 0);
+}
+
static bool select_item(Ctrl *c, int idx, Move *out)
{
if (idx < 0 || idx >= c->nitems) return false;
@@ -596,7 +611,11 @@ static bool select_item(Ctrl *c, int idx, Move *out)
Move m;
switch (c->intent) {
case I_VICTIM:
- m = base_move(c, M_ROBBER); m.a = (int16_t)c->pending_hex; m.b = it->arg; m.c = -1;
+ robber_confirm(c, c->pending_hex, it->arg);
+ return false;
+ case I_ROBBER_OK:
+ if (!it->action) { enter_pick(c, CM_HEX, I_ROBBER); return false; }
+ m = base_move(c, M_ROBBER); m.a = (int16_t)c->pending_hex; m.b = (int16_t)c->pending_victim; m.c = -1;
return finish(c, out, m);
case I_RESPOND:
if (it->action == 2) { /* contraoferta: arranca de la oferta al reves */
@@ -801,7 +820,7 @@ static bool mandatory(const Ctrl *c)
{
switch (c->intent) {
case I_SETUP_S: case I_SETUP_R: case I_ROBBER: case I_NEUTRAL: case I_DISCARD: case I_GOLD:
- case I_GIVEBACK: case I_RESPOND: case I_VICTIM:
+ case I_GIVEBACK: case I_RESPOND: case I_VICTIM: case I_ROBBER_OK:
return true;
}
return false;
@@ -815,6 +834,7 @@ bool ctrl_can_back(const Ctrl *c)
if (c->mode == CM_IDLE || c->mode == CM_OVER || c->mode == CM_HANDOFF) return false;
if (c->g && c->g->phase == PH_SETUP && !c->g->setup_sub && c->draft_v >= 0) return true; /* deshacer un paso */
if (c->mode == CM_MENU && c->intent == I_OFFER_TO) return true; /* vuelve a las cartas */
+ if (c->mode == CM_MENU && (c->intent == I_VICTIM || c->intent == I_ROBBER_OK)) return true; /* otro lugar para el ladron */
if (c->mode == CM_CARDS && mandatory(c)) return false; /* solo borra la seleccion */
return !mandatory(c);
}
@@ -875,6 +895,7 @@ bool ctrl_key(Ctrl *c, int key, Move *out)
else if (key == K_OK || (key == ' ' && c->intent != I_OFFER_TO)) return select_item(c, c->icursor, out);
else if (key == K_BACK) {
if (c->intent == I_OFFER_TO) { c->mode = CM_CARDS; c->intent = I_OFFER; }
+ else if (c->intent == I_VICTIM || c->intent == I_ROBBER_OK) enter_pick(c, CM_HEX, I_ROBBER); /* otro lugar */
else if (!mandatory(c)) enter_default(c);
} else if (key == ' ' && c->intent == I_OFFER_TO) return select_item(c, c->icursor, out);
else
@@ -975,6 +996,7 @@ const char *ctrl_prompt(const Ctrl *c)
case I_NEUTRAL: sb_s(&s, tr(c->mode == CM_VERT ? S_P_NEUTRAL_S : S_P_NEUTRAL_R)); break;
case I_ROBBER: sb_s(&s, tr(S_P_ROBBER)); break;
case I_VICTIM: sb_s(&s, tr(S_P_VICTIM)); break;
+ case I_ROBBER_OK: sb_s(&s, tr(S_P_ROBBER_OK)); break;
case I_DISCARD: sb_s(&s, tr(S_P_DISCARD)); sb_s(&s, " "); sb_i(&s, res_total(c->sel)); sb_s(&s, "/"); sb_i(&s, c->need); break;
case I_GIVEBACK: sb_s(&s, tr(S_P_GIVEBACK)); sb_s(&s, " "); sb_i(&s, res_total(c->sel)); sb_s(&s, "/"); sb_i(&s, c->need); break;
case I_PLENTY: sb_s(&s, tr(S_P_PLENTY)); break;
@@ -991,13 +1013,14 @@ const char *ctrl_prompt(const Ctrl *c)
case I_DEV: sb_s(&s, tr(S_P_DEV)); break;
case I_RESPOND:
sb_s(&s, c->names[g->offer.from]); sb_s(&s, " "); sb_s(&s, tr(S_P_RESPOND)); sb_s(&s, ": ");
- sb_res(&s, g->offer.give); sb_s(&s, " → "); sb_res(&s, g->offer.get);
+ sb_s(&s, tr(S_GIVES_YOU)); sb_s(&s, " "); sb_res(&s, g->offer.give); sb_s(&s, ", ");
+ sb_s(&s, tr(S_WANTS_YOU)); sb_s(&s, " "); sb_res(&s, g->offer.get);
break;
default:
if (g->phase == PH_ROLL) sb_s(&s, tr(S_P_ROLL));
else if (g->offer.active && g->offer.from == c->actor) {
- sb_s(&s, tr(S_OFFERS)); sb_s(&s, " "); sb_res(&s, g->offer.give); sb_s(&s, " "); sb_s(&s, tr(S_FOR)); sb_s(&s, " ");
- sb_res(&s, g->offer.get); sb_s(&s, " — "); sb_s(&s, tr(S_WAITING_RESP));
+ sb_s(&s, tr(S_OFFERS)); sb_s(&s, ": "); sb_s(&s, tr(S_GIVES)); sb_s(&s, " "); sb_res(&s, g->offer.give); sb_s(&s, ", ");
+ sb_s(&s, tr(S_WANTS)); sb_s(&s, " "); sb_res(&s, g->offer.get); sb_s(&s, " — "); sb_s(&s, tr(S_WAITING_RESP));
} else sb_s(&s, tr(g->phase == PH_PAIRED ? S_P_PAIRED : S_P_MAIN));
break;
}
@@ -1063,7 +1086,8 @@ int move_describe(const Game *g, const Move *m, const char *const names[MAXP], c
sb_s(&s, " → "); sb_i(&s, m->c); sb_s(&s, " "); sb_s(&s, res_name(m->b));
break;
case M_OFFER:
- sb_s(&s, tr(S_OFFERS)); sb_s(&s, " "); sb_res(&s, m->r); sb_s(&s, " "); sb_s(&s, tr(S_FOR)); sb_s(&s, " "); sb_res(&s, m->r2);
+ sb_s(&s, tr(S_OFFERS)); sb_s(&s, ": "); sb_s(&s, tr(S_GIVES)); sb_s(&s, " "); sb_res(&s, m->r); sb_s(&s, ", ");
+ sb_s(&s, tr(S_WANTS)); sb_s(&s, " "); sb_res(&s, m->r2);
if (m->a != -1 && m->a != game_offer_candidates(g, m->p)) {
sb_s(&s, " ("); sb_s(&s, tr(S_TO));
bool first = true;
@@ -1074,7 +1098,8 @@ int move_describe(const Game *g, const Move *m, const char *const names[MAXP], c
break;
case M_ACCEPT: sb_s(&s, tr(S_ACCEPTS)); break;
case M_COUNTER:
- sb_s(&s, tr(S_COUNTERS)); sb_s(&s, " "); sb_res(&s, m->r); sb_s(&s, " "); sb_s(&s, tr(S_FOR)); sb_s(&s, " "); sb_res(&s, m->r2);
+ sb_s(&s, tr(S_COUNTERS)); sb_s(&s, ": "); sb_s(&s, tr(S_GIVES)); sb_s(&s, " "); sb_res(&s, m->r); sb_s(&s, ", ");
+ sb_s(&s, tr(S_WANTS)); sb_s(&s, " "); sb_res(&s, m->r2);
break;
case M_REJECT: sb_s(&s, tr(S_REJECTS)); break;
case M_CONFIRM: sb_s(&s, tr(S_TRADED_WITH)); sb_s(&s, " "); sb_s(&s, names[m->a]); break;
diff --git a/catan/core/ctrl.h b/catan/core/ctrl.h
@@ -15,6 +15,7 @@ enum Intent {
I_NONE, I_SETUP_S, I_SETUP_R, I_SETTLE, I_ROAD, I_CITY, I_FREE_ROAD, I_NEUTRAL,
I_ROBBER, I_VICTIM, I_DISCARD, I_GIVEBACK, I_ACTIONS, I_DEV, I_PLENTY, I_MONO,
I_BANK_GIVE, I_BANK_GET, I_OFFER, I_RESPOND, I_OFFER_TO, I_CONFIRM_END, I_COUNTER, I_SETUP_OK,
+ I_ROBBER_OK, /* confirmar adonde va el ladron (y a quien roba) antes de que robe */
I_SHIP, I_MOVESHIP_FROM, I_MOVESHIP_TO, I_GOLD
};
@@ -73,6 +74,7 @@ typedef struct {
int pos; /* donde esta el cursor (vertice/arista/hex), valido o no */
int last_x, last_y; /* posicion del cursor (coords de topologia x2) */
int pending_hex;
+ int pending_victim; /* a quien se le roba (o -1) mientras se confirma el ladron */
MenuItem items[24];
int nitems, icursor;
diff --git a/catan/core/strings.h b/catan/core/strings.h
@@ -131,6 +131,14 @@
X(S_P_ROBBER, "Elegí adónde mover el ladrón", "Choose where to move the robber") \
X(S_NOT_HERE, "Ahí no se puede (Tab: próximo lugar válido)", "Not allowed there (Tab: next valid spot)") \
X(S_P_VICTIM, "¿A quién le robás?", "Steal from whom?") \
+ X(S_P_ROBBER_OK, "¿Mover el ladrón ahí?", "Move the robber there?") \
+ X(S_ROBBER_OK, "Sí, mover y robarle a", "Yes, move and steal from") \
+ X(S_ROBBER_OK_NO, "Sí, mover (no hay a quién robar)", "Yes, move (nobody to steal from)") \
+ X(S_ROBBER_ELSE, "Elegir otro lugar", "Choose another place") \
+ X(S_GIVES, "da", "gives") \
+ X(S_WANTS, "pide", "wants") \
+ X(S_GIVES_YOU, "te da", "gives you") \
+ X(S_WANTS_YOU, "te pide", "wants from you") \
X(S_P_DISCARD, "Descartá cartas:", "Discard cards:") \
X(S_P_GIVEBACK, "Devolvé cartas:", "Give back cards:") \
X(S_P_PLENTY, "Elegí 2 recursos del banco", "Pick 2 resources from the bank") \
diff --git a/catan/gfx/board.c b/catan/gfx/board.c
@@ -79,14 +79,16 @@ void geom_port(const BoardGeom *g, int i, int *x, int *y)
{
int mx, my, e = g->t->port_edge[i];
geom_edge(g, e, &mx, &my);
+ /* el disco va del lado del agua, perpendicular a la arista: con mar, hacia el hexagono
+ * de agua; sin mar, alejandose del hexagono de tierra (la direccion desde el centro
+ * del tablero caia a veces sobre un vertice o adentro de la casilla) */
int dx = mx - g->cx, dy = my - g->cy;
- for (int s = 0; s < 2; s++) { /* con mar: hacia el hexagono de agua de ese lado */
- int h = g->t->edge_hex[e][s];
- if (h >= 0 && g->t->region[h] < 0) {
- int hx, hy;
- geom_hex(g, h, &hx, &hy);
- dx = hx - mx; dy = hy - my;
- }
+ for (int s = 0; s < 2; s++) {
+ int h = g->t->edge_hex[e][s], hx, hy;
+ if (h < 0) continue;
+ geom_hex(g, h, &hx, &hy);
+ if (g->t->region[h] < 0) { dx = hx - mx; dy = hy - my; break; }
+ dx = mx - hx; dy = my - hy;
}
int len = isqrt(dx * dx + dy * dy);
if (!len) len = 1;
@@ -225,7 +227,8 @@ static void token(Surface *s, const BoardGeom *g, int h, int num)
}
}
-static void port(Surface *s, const BoardGeom *g, const Game *gm, int i, int flags)
+/* los muelles van debajo de rutas y pueblos; el disco con el tipo, encima (port_disc) */
+static void port(Surface *s, const BoardGeom *g, int i)
{
const Topo *t = g->t;
int px, py, x, y;
@@ -234,6 +237,12 @@ static void port(Surface *s, const BoardGeom *g, const Game *gm, int i, int flag
geom_vert(g, t->edge_v[t->port_edge[i]][k], &x, &y);
gfx_line(s, x, y, px, py, g->R / 10 + 1, RGB(120, 84, 44));
}
+}
+
+static void port_disc(Surface *s, const BoardGeom *g, const Game *gm, int i, int flags)
+{
+ int px, py;
+ geom_port(g, i, &px, &py);
int r = g->R * 28 / 100;
if (r < 2) r = 2;
int kind = gm->port[i];
@@ -260,7 +269,7 @@ void gfx_board(Surface *s, const BoardGeom *g, const Game *gm, const Ctrl *c, in
if ((flags & GB_DETAIL) && R >= 16)
for (int h = 0; h < g->t->nhex; h++) detail(s, g, h, gm->terrain[h]);
if (flags & GB_PORTS)
- for (int i = 0; i < g->t->nport; i++) port(s, g, gm, i, flags);
+ for (int i = 0; i < g->t->nport; i++) port(s, g, i);
if (flags & GB_TOKENS)
for (int h = 0; h < g->t->nhex; h++)
if (gm->num[h]) token(s, g, h, gm->num[h]);
@@ -380,6 +389,8 @@ void gfx_board(Surface *s, const BoardGeom *g, const Game *gm, const Ctrl *c, in
gfx_ring(s, x, y, R * 50 / 100 + 1, 4, C_OUTLINE);
gfx_ring(s, x, y, R * 50 / 100, 3, ccol);
}
+ if (flags & GB_PORTS) /* el tipo de puerto siempre visible, encima de todo */
+ for (int i = 0; i < g->t->nport; i++) port_disc(s, g, gm, i, flags);
}
int gfx_board_hit(const BoardGeom *g, const Ctrl *c, int x, int y)
diff --git a/catan/linux/selftest.c b/catan/linux/selftest.c
@@ -1327,6 +1327,44 @@ static void t_websocket(void)
CHECK(unwrap(buf, 1014, out, sizeof out, &got, &ctl) == -1);
}
+/* El ladron se confirma antes de robar (un toque de mas no se puede deshacer: el robo es
+ * al azar): hexagono -> (victima) -> confirmar; Esc o "otro lugar" vuelven al tablero. */
+static void t_ctrl_robber(void)
+{
+ static Auth a;
+ Opts o;
+ opts_default(&o, 3, false);
+ o.beginner = 1;
+ auth_new(&a, &o, 11, NULL, NULL);
+ Game *g = &a.g;
+ const Topo *t = game_topo(g);
+ static const char *nm[MAXP] = { "A", "B", "C", "D" };
+ /* un hex sin nadie (h0) y otro con un pueblo de B con cartas (h1), ninguno con el ladron */
+ int h0 = -1, h1 = -1;
+ for (int h = 0; h < t->nhex && (h0 < 0 || h1 < 0); h++) {
+ if (h == g->robber || !ter_land(g->terrain[h])) continue;
+ if (h0 < 0) { h0 = h; continue; }
+ h1 = h;
+ }
+ place(g, 1, t->hex_vert[h1][0], 1);
+ g->p[1].res[GRAIN] += 2; g->bank[GRAIN] -= 2;
+ g->phase = PH_ROBBER; g->cur = 0; g->rolled = 1;
+ static Ctrl c;
+ ctrl_init(&c, 1, false);
+ for (int i = 0; i < MAXP; i++) c.names[i] = nm[i];
+ ctrl_sync(&c, g);
+ CHECK(c.mode == CM_HEX && c.intent == I_ROBBER);
+ Move m;
+ CHECK(!ctrl_pick(&c, PK_HEX, h0, &m) && c.mode == CM_MENU && c.intent == I_ROBBER_OK && c.pending_victim < 0);
+ CHECK(ctrl_can_back(&c));
+ CHECK(!ctrl_key(&c, K_BACK, &m) && c.mode == CM_HEX && c.intent == I_ROBBER); /* otro lugar */
+ CHECK(!ctrl_pick(&c, PK_HEX, h1, &m) && c.mode == CM_MENU && c.intent == I_ROBBER_OK && c.pending_victim == 1);
+ CHECK(!ctrl_key(&c, 'v', &m) && c.mode == CM_HEX); /* "elegir otro lugar" */
+ CHECK(!ctrl_pick(&c, PK_HEX, h1, &m) && c.mode == CM_MENU);
+ CHECK(ctrl_key(&c, 'e', &m) && m.type == M_ROBBER && m.a == h1 && m.b == 1); /* confirma: recien ahi sale la movida */
+ CHECK(auth_submit(&a, &m) == E_OK && g->robber == h1 && g->p[1].res[GRAIN] == 1 && g->p[0].res[GRAIN] == 1);
+}
+
int app_selftest(void)
{
printf("selftest del nucleo\n");
@@ -1352,6 +1390,7 @@ int app_selftest(void)
t_ctrl_counter();
t_ctrl_nav();
t_ctrl_setup();
+ t_ctrl_robber();
t_serialize();
t_replay();
t_freeplay();