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
20 changes: 20 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2509,3 +2509,23 @@ que diuen les regles del Rummikub.»
desats d'abans, temporada començada que conserva les jornades) i del repte
acabat; la prova de navegador de la temporada sencera comprova que no hi ha
comptador i que les voltes canvien les jornades (6 → 3).

### Lligues dels Països Catalans sense noms castellans ✅ Feta (2026-11-14)

Demanat pel jugador: «Substitueix els noms i cognoms castellans de totes les
lligues dins dels Països Catalans, des de les locals, a Remigi i El Tauler.»

- [x] `nomsLliga.js` tornat a copiar d'El Tauler (segueix/eltauler,
`nomslliga.js`): la barreja d'altres orígens de cada territori ja no té
noms castellans ni de l'Amèrica hispana (la seva part passa als noms del
territori), i els noms valencians i de la Franja ja no tenen formes
castellanes. Nous: `isCastilianName`, `catalanRivalName`, `CATALAN_FORMS`.
- [x] `catalanRivals` (`league.ts`): una temporada començada amb noms
castellans els canvia per catalans del territori en carregar-la, sempre
els mateixos (llavor de la temporada); els resultats van per identificador.

**Criteris d'acceptació (verificats)**: cap nom castellà ni forma castellana
del graó 0 al 14 des dels set territoris (20 llavors per graó); una temporada
desada amb noms castellans es carrega amb noms catalans, la resta igual i de
manera determinista; a Europa no es toca. El castellà es manté a Europa, al
món i per a qui juga des de fora.
50 changes: 49 additions & 1 deletion apps/web/src/lliga/league.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ import {
zoneSize,
type LeagueSeason,
} from './league';
import type { LeagueHome } from './localitats';
import { normalizeHome, type LeagueHome } from './localitats';
import { CATALAN_FORMS, isCastilianName } from './nomsLliga';

const VIC: LeagueHome = { territory: 'cat', municipi: 'Vic', comarca: 'Osona', provincia: 'Barcelona', pais: '' };

Expand Down Expand Up @@ -216,6 +217,53 @@ describe('els rivals', () => {
expect(a).toEqual(b);
});

it('cap nom castellà del municipi als Països Catalans, des de cap territori', () => {
const homes = (
[['cat', 'Vic'], ['val', 'Gandia'], ['bal', 'Palma'], ['and', 'Andorra la Vella'], ['nord', 'Perpinyà'],
['franja', 'Fraga'], ['alguer', "l'Alguer"]] as const
).map(([territory, municipi]) => normalizeHome({ territory, municipi }));
const forms = new Set(Object.keys(CATALAN_FORMS));
const config = { opponents: 2 as const, players: 6, legs: 1 as const, rounds: 3 };
for (const home of homes) {
// Del graó 0 (Municipal C) al 14 (Països Catalans A).
for (let tier = 0; tier <= 14; tier++) {
for (let seed = 1; seed <= 20; seed++) {
for (const rival of createSeason({ tier, config, home, seed }).rivals) {
expect(isCastilianName(rival.name)).toBe(false);
for (const word of rival.name.split(' ')) expect(forms.has(word)).toBe(false);
}
}
}
}
}, 30_000);

it('una temporada començada amb noms castellans, en carregar-la, els passa a català', () => {
const season = recordRound(createSeason({ tier: TERRITORIAL_TIER, config: DEFAULT_CONFIG, home: VIC, seed: 5 }), [40, -10, -10, -20]);
const old = {
...season,
rivals: season.rivals.map((r, i) => (i === 0 ? { ...r, name: 'Lucía Navarro' } : i === 1 ? { ...r, name: 'Carmen Ferrer' } : r)),
};
const raw = JSON.stringify({ career: newCareer(VIC), season: old });
const parsed = parseLeagueState(raw).season!;
expect(isCastilianName(parsed.rivals[0].name)).toBe(false);
expect(parsed.rivals[0].name).toMatch(/^\S+ \S+/);
expect(parsed.rivals[1].name).toBe('Carme Ferrer');
// La resta, els identificadors i els resultats, tal com eren.
expect(parsed.rivals.slice(2)).toEqual(season.rivals.slice(2));
expect(parsed.rivals.map((r) => r.id)).toEqual(season.rivals.map((r) => r.id));
expect(parsed.results).toEqual(season.results);
expect(new Set(parsed.rivals.map((r) => r.name)).size).toBe(parsed.rivals.length);
// El nom nou no repeteix el cognom de cap altre rival.
const surname = (name: string) => name.split(' ').slice(1).join(' ');
expect(parsed.rivals.slice(1).map((r) => surname(r.name))).not.toContain(surname(parsed.rivals[0].name));
// Cada càrrega, els mateixos noms.
expect(parseLeagueState(raw).season!.rivals).toEqual(parsed.rivals);
// A Europa (graó 15) no es toca.
const europe = createSeason({ tier: 15, config: DEFAULT_CONFIG, home: VIC, seed: 5 });
const spanish = { ...europe, rivals: europe.rivals.map((r, i) => (i === 0 ? { ...r, name: 'Lucía Navarro' } : r)) };
expect(parseLeagueState(JSON.stringify({ career: newCareer(VIC), season: spanish })).season!.rivals[0].name).toBe('Lucía Navarro');
});

it('no repeteix els noms de les temporades anteriors', () => {
const first = createSeason({ tier: 0, config: DEFAULT_CONFIG, home: VIC, seed: 3 });
const exclude = first.rivals.map((r) => r.name);
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/lliga/league.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type Rng,
} from '@remigi/core';
import { islandGroup, originTerritory, rivalOrigins, type LeagueHome } from './localitats';
import { rivalNames } from './nomsLliga';
import { catalanRivalName, fold, rivalNames } from './nomsLliga';

/**
* La lliga de Remigi (docs/LLIGA.md): la mateixa escala que les lligues
Expand Down Expand Up @@ -997,13 +997,34 @@ export function parseLeagueState(raw: string | null): LeagueState {
const season = validSeason(value.season);
// Els noms d'abans («· Grup B», «· Lliga B») amb el d'ara («· Divisió B»).
if (season) season.name = renameDivision(season.name);
if (season) season.rivals = catalanRivals(season, career.home);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize renamed rivals with the saved league game

For users upgrading with a league round already in progress, this renames the season roster but leaves the separately persisted game's names unchanged. In App.tsx, playRound resumes a matching session using saved.setup and saved.game directly, so the same rival now has a Catalan name in the standings but its old Spanish name and avatar during the resumed round. Migrate the saved game's opponent names and player names consistently with the season roster, without discarding the in-progress game.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cert: una jornada de lliga a mig jugar d'abans del canvi es reprèn amb saved.setup i saved.game, i el rival hi conserva el nom d'abans fins que s'acaba aquella partida; el resultat va per identificador i la classificació ja surt amb el nom nou. Per sincronitzar-ho cal passar el mapa de noms canviats (catalanRivals) a la partida desada i reanomenar-hi els jugadors. Va al proper canvi.


Generated by Claude Code

career.seasons = career.seasons.map((record) => ({ ...record, name: renameDivision(record.name) }));
return { career, season };
} catch {
return { career: newCareer(), season: null };
}
}

/**
* Rivals d'una temporada començada quan encara hi sortien noms castellans: a
* les lligues dels Països Catalans (del municipi als Països Catalans) passen a
* tenir nom català del seu territori (`catalanRivalName`). Els resultats van
* per identificador, així que no cal tocar res més. La llavor és la de la
* temporada: cada càrrega dona els mateixos noms.
*/
export function catalanRivals(season: LeagueSeason, home: LeagueHome | null): LeagueRival[] {
const level = tierLevel(season.tier);
const options = {
random: createRng((season.seed ^ 0x9e3779b9) >>> 0),
taken: new Set(season.rivals.map((rival) => fold(rival.name))),
territoryOf: originTerritory,
};
return season.rivals.map((rival) => {
const name = catalanRivalName(rival.name, level, home, rival.origin, options);
return name === rival.name ? rival : { ...rival, name };
});
}

export function serializeLeagueState(state: LeagueState): string {
return JSON.stringify(state);
}
27 changes: 27 additions & 0 deletions apps/web/src/lliga/nomsLliga.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,30 @@ export function rivalNames(
territoryOf?: (city: string) => TerritoryId | null;
},
): string[];
/** Un nom fet amb un nom i un cognom castellans (García, Martínez...). */
export function isCastilianName(name: string): boolean;
/**
* El nom d'un rival d'una temporada ja començada, sense res de castellà a les
* lligues dels Països Catalans (nivells 0-4): un nom castellà en dona un de nou
* del territori (cap dels de `taken`, que s'hi afegeix) i les formes
* castellanes d'abans passen a la catalana. A Europa, al món i per a qui juga
* des de fora, el nom no es toca.
*/
export function catalanRivalName(
name: string,
level: number,
home: LeagueHome | null,
origin: string | null,
options?: {
random?: () => number;
taken?: Set<string>;
territoryOf?: (city: string) => TerritoryId | null;
},
): string;
/** Formes castellanes d'abans entre els noms dels territoris → la catalana. */
export const CATALAN_FORMS: Readonly<Record<string, string>>;
/** Cultures del castellà, que no surten a les lligues dels Països Catalans. */
export const CASTILIAN_CULTURES: readonly string[];
export const DIVERSE: Readonly<Record<string, readonly (readonly [string, number])[]>>;
export const TERRITORY_CULTURE: Readonly<Record<string, string>>;
export const CULTURES: Readonly<Record<string, { f: string[]; m: string[]; s?: string[]; sf?: string[]; sm?: string[]; order?: string }>>;
127 changes: 104 additions & 23 deletions apps/web/src/lliga/nomsLliga.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ const CULTURES = {
'Vilaró', 'Bertran', 'Canals', 'Galí', 'Oliveras', 'Rierola']
},
val: {
f: ['Amparo', 'Pepa', 'Vicenta', 'Empar', 'Rosa', 'Neus', 'Llum', 'Carme', 'Lourdes', 'Inma', 'Xelo', 'Maria'],
m: ['Vicent', 'Ximo', 'Pepe', 'Toni', 'Xavi', 'Batiste', 'Joanjo', 'Quique', 'Rafa', 'Pau', 'Nelo', 'Paco'],
s: ['Ferrer', 'Peris', 'Climent', 'Llopis', 'Gisbert', 'Martí', 'Bataller', 'Soriano', 'Escrivà', 'Sanchis',
'Alemany', 'Monzó', 'Ballester', 'Mompó', 'Tormo', 'Boix', 'Cervera', 'Calatayud', 'Sendra', 'Gil']
f: ['Fina', 'Pepa', 'Vicenta', 'Empar', 'Rosa', 'Neus', 'Llum', 'Carme', 'Remei', 'Imma', 'Xelo', 'Maria'],
m: ['Vicent', 'Ximo', 'Pasqual', 'Toni', 'Xavi', 'Batiste', 'Joanjo', 'Quico', 'Rafel', 'Pau', 'Nelo', 'Cento'],
s: ['Ferrer', 'Peris', 'Climent', 'Llopis', 'Gisbert', 'Martí', 'Bataller', 'Fuster', 'Escrivà', 'Sanchis',
'Alemany', 'Monzó', 'Ballester', 'Mompó', 'Tormo', 'Boix', 'Cervera', 'Ribes', 'Sendra', 'Gil']
},
bal: {
f: ['Aina', 'Joana', 'Margalida', 'Catalina', 'Francina', 'Antònia', 'Maria', 'Bel', 'Xisca', 'Apol·lònia'],
Expand All @@ -46,17 +46,20 @@ const CULTURES = {
'Ribas', 'Babot', 'Naudi', 'Riba']
},
franja: {
f: ['Pilar', 'Carmen', 'Rosa', 'Teresa', 'Núria', 'Montse', 'Ana', 'Mercè'],
f: ['Pilar', 'Carme', 'Rosa', 'Teresa', 'Núria', 'Montse', 'Anna', 'Mercè'],
m: ['Josep', 'Ramon', 'Joaquim', 'Manel', 'Jesús', 'Àngel', 'Xavier', 'Hèctor'],
s: ['Lacasa', 'Salvador', 'Pallarés', 'Arnau', 'Sancho', 'Bielsa', 'Castell', 'Monclús', 'Gràcia',
'Llovet', 'Blasco', 'Ballarín', 'Moliné', 'Abella']
s: ['Ricart', 'Salvador', 'Pallarès', 'Arnau', 'Farré', 'Fontanet', 'Castell', 'Monclús', 'Gràcia',
'Llovet', 'Calvet', 'Esteve', 'Moliné', 'Abella']
},
alguer: {
f: ['Maria', 'Antonella', 'Giovanna', 'Caterina', 'Rita', 'Paola', 'Margalida'],
m: ['Antoni', 'Joan', 'Gavino', 'Francesc', 'Salvatore', 'Pietro', 'Miquel'],
s: ['Sechi', 'Piras', 'Mura', 'Carta', 'Pinna', 'Delogu', 'Scanu', 'Manca', 'Pais', 'Fois', 'Serra', 'Bardino']
},
// ── Gent d'altres orígens que viu als territoris ──
// (els noms castellans i de l'Amèrica hispana ja no surten a les lligues
// dels Països Catalans: només a Europa i al món, i per a qui juga des
// de fora; vegeu DIVERSE)
es: {
f: ['Laura', 'Lucía', 'Cristina', 'Sara', 'Raquel', 'Elena', 'Paula', 'Silvia', 'Beatriz', 'Irene'],
m: ['Sergio', 'Javier', 'Alejandro', 'David', 'Álvaro', 'Rubén', 'Iván', 'Pablo', 'Daniel', 'Adrián'],
Expand Down Expand Up @@ -390,17 +393,30 @@ const CULTURES = {
};

// Cultura de cada territori dels Països Catalans, i de quins altres
// orígens és la gent que hi viu (pesos).
// orígens és la gent que hi viu, en percentatge de la proporció de cada
// nivell (DIVERSITY_BY_LEVEL). El que falta fins a 100 són noms del
// territori: abans hi havia noms castellans i de l'Amèrica hispana, i ara
// a les lligues dels Països Catalans tots els noms i cognoms són catalans
// o d'altres llengües.
const TERRITORY_CULTURE = { cat: 'cat', val: 'val', bal: 'bal', and: 'and', nord: 'nord', franja: 'franja', alguer: 'alguer' };
const DIVERSE = {
cat: [['es', 55], ['latam', 15], ['ma', 12], ['ro', 8], ['cn', 5], ['pk', 5]],
val: [['es', 65], ['latam', 10], ['ro', 15], ['ma', 10]],
bal: [['es', 55], ['de', 15], ['latam', 15], ['ma', 10], ['it', 5]],
and: [['es', 45], ['pt', 45], ['fr', 10]],
cat: [['ma', 12], ['ro', 8], ['cn', 5], ['pk', 5]],
val: [['ro', 15], ['ma', 10]],
bal: [['de', 15], ['ma', 10], ['it', 5]],
and: [['pt', 45], ['fr', 10]],
nord: [['fr', 85], ['ma', 15]],
franja: [['es', 85], ['ro', 15]],
franja: [['ro', 15]],
alguer: [['it', 100]]
};
// Cultures del castellà: no surten mai a les lligues dels Països Catalans.
const CASTILIAN_CULTURES = ['es', 'latam'];
// Formes castellanes que hi havia entre els noms dels territoris, i la
// catalana que les substitueix (per als rivals de temporades ja començades).
const CATALAN_FORMS = {
'Amparo': 'Fina', 'Lourdes': 'Remei', 'Inma': 'Imma', 'Pepe': 'Pasqual', 'Quique': 'Quico', 'Paco': 'Cento',
'Rafa': 'Rafel', 'Soriano': 'Fuster', 'Calatayud': 'Ribes', 'Carmen': 'Carme', 'Ana': 'Anna', 'Lacasa': 'Ricart',
'Pallarés': 'Pallarès', 'Sancho': 'Farré', 'Bielsa': 'Fontanet', 'Blasco': 'Calvet', 'Ballarín': 'Esteve'
};
// Proporció de rivals d'altres orígens als nivells de casa (municipal,
// comarcal, provincial, territori) i als Països Catalans.
const DIVERSITY_BY_LEVEL = [0, 0.12, 0.25, 0.4, 0.2];
Expand Down Expand Up @@ -464,29 +480,90 @@ function makeName(code, random) {
// Cultura del nom d'un rival segons el nivell (0 municipal ... 6 mundial),
// la localitat del jugador i l'origen del rival. options.territoryOf: ciutat
// dels Països Catalans → territori (localitats.js).
// Territori d'on surt el nom d'un rival dels nivells de casa i dels Països
// Catalans (0-4): el del jugador o, als Països Catalans i a la Lliga
// Catalana de qui juga a la Catalunya Nord (rivals del Principat i del
// Nord), el d'on és el rival.
function territoryFor(level, home, origin, opts) {
let territory = (home && home.territory) || 'cat';
if (level === 4 || (level === 3 && territory === 'nord')) {
const t = opts && typeof opts.territoryOf === 'function' ? opts.territoryOf(origin) : null;
territory = t || territory;
}
return territory;
}

function cultureFor(level, home, origin, options) {
const opts = options || {};
const rnd = typeof opts.random === 'function' ? opts.random : Math.random;
const h = home || {};
if (level >= 5) return countryCulture(origin) || pick(EUROPE_CULTURES, rnd);
let territory = h.territory || 'cat';
// Als Països Catalans, i a la Lliga Catalana de qui juga a la Catalunya
// Nord (rivals del Principat i del Nord), el nom va amb el territori
// d'on és el rival.
if (level === 4 || (level === 3 && territory === 'nord')) {
const t = typeof opts.territoryOf === 'function' ? opts.territoryOf(origin) : null;
territory = t || territory;
}
const territory = territoryFor(level, h, origin, opts);
if (territory === 'fora') return countryCulture(h.pais) || pick(EUROPE_CULTURES, rnd);
// A l'Alguer, de la Nurra a Sardenya la majoria de rivals no són
// algueresos: noms italians (cada nivell, més).
if (territory === 'alguer' && level >= 1 && level <= 3 && rnd() < ALGUER_ITALIAN_BY_LEVEL[level]) return 'it';
const base = TERRITORY_CULTURE[territory] || 'cat';
const share = DIVERSITY_BY_LEVEL[Math.max(0, Math.min(4, level))] || 0;
if (share > 0 && rnd() < share && DIVERSE[territory]) return pickWeighted(DIVERSE[territory], rnd);
const others = DIVERSE[territory];
if (share > 0 && others && rnd() < share) {
const percent = others.reduce(function (sum, p) { return sum + p[1]; }, 0);
if (rnd() * 100 < percent) return pickWeighted(others, rnd);
}
return base;
}

// Noms de pila i cognoms del castellà (cultures es i latam).
const CASTILIAN_GIVEN = new Set(), CASTILIAN_SURNAMES = new Set();
CASTILIAN_CULTURES.forEach(function (code) {
const c = CULTURES[code];
c.f.concat(c.m).forEach(function (n) { CASTILIAN_GIVEN.add(n); });
(c.s || []).forEach(function (n) { CASTILIAN_SURNAMES.add(n); });
});

// Un nom fet amb un nom i un cognom castellans (García, Martínez...).
function isCastilianName(name) {
const parts = String(name == null ? '' : name).trim().split(/\s+/);
return parts.length >= 2 && CASTILIAN_GIVEN.has(parts[0]) && CASTILIAN_SURNAMES.has(parts.slice(1).join(' '));
}

// El nom d'un rival d'una temporada ja començada, sense res de castellà a
// les lligues dels Països Catalans (nivells 0-4): un nom castellà en dona
// un de nou del territori (cap dels de options.taken, que s'hi afegeix), i
// les formes castellanes d'abans passen a la catalana (Carmen, Carme). A
// Europa, al món i per a qui juga des de fora, el nom no es toca.
function catalanRivalName(name, level, home, origin, options) {
const opts = options || {};
if (typeof name !== 'string' || !(level >= 0 && level <= 4)) return name;
const territory = territoryFor(level, home || {}, origin, opts);
if (territory === 'fora') return name;
if (!isCastilianName(name)) {
return name.split(' ').map(function (w) { return CATALAN_FORMS[w] || w; }).join(' ');
Comment on lines +540 to +541

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve converted names before generating replacements

When an existing season contains both Spanish names and territorial names needing conversion, generated replacements can collide with the later converted names. The conversion branch neither checks nor updates taken, which initially contains only the original names. For example, an old level-4 season generated with seed 6366, a Gandia home and 12 players contains both Iván Delgado and Amparo Calatayud; migration changes both to Fina Ribes. Reserve the converted names before generating replacements and check uniqueness across the final roster so different rivals remain distinguishable in standings and fixtures.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cert: la branca de les formes castellanes (CATALAN_FORMS) ni comprova ni actualitza taken, i un nom nou pot coincidir amb una forma convertida més avall (Amparo Calatayud → Fina Ribes). Només passa en temporades d'abans que tinguin alhora un nom castellà i una forma castellana d'aquell territori, i que l'atzar triï justament aquella combinació. La correcció: primer convertir les formes de tota la llista i reservar-les a taken, i després generar els noms nous (també a nomslliga.js d'El Tauler, d'on es copia). Va al proper canvi.


Generated by Claude Code

}
const rnd = typeof opts.random === 'function' ? opts.random : Math.random;
const code = TERRITORY_CULTURE[territory] || 'cat';
const taken = opts.taken instanceof Set ? opts.taken : new Set();
// Com a rivalNames: un nom que no hi sigui i, si es pot, sense repetir
// el nom de pila ni el cognom de cap altre rival de la lliga.
const usedGiven = new Set(), usedSurnames = new Set();
taken.forEach(function (key) {
const parts = String(key).split(' ');
usedGiven.add(parts[0]);
usedSurnames.add(parts.slice(1).join(' '));
});
let best = null, bestScore = -1;
for (let k = 0; k < 80 && bestScore < 2; k++) {
const cand = makeName(code, rnd);
const key = fold(cand.name);
if (taken.has(key) || FAMOUS.has(key)) continue;
const score = (usedSurnames.has(fold(cand.surname)) ? 0 : 1) + (usedGiven.has(fold(cand.given)) ? 0 : 1);
if (score > bestScore) { best = cand; bestScore = score; }
}
const got = best ? best.name : makeName(code, rnd).name;
taken.add(fold(got));
return got;
}

// Noms dels rivals d'una lliga nova: count noms diferents (i, si es pot,
// sense repetir nom de pila ni cognom), cap dels d'options.exclude (els de
// les darreres temporades). origins: d'on és cada rival (localitats.js:
Expand Down Expand Up @@ -531,12 +608,16 @@ export {
CULTURES,
COUNTRY_CULTURE,
DIVERSE,
CASTILIAN_CULTURES,
CATALAN_FORMS,
DIVERSITY_BY_LEVEL,
TERRITORY_CULTURE,
FAMOUS,
fold,
countryCulture,
makeName,
cultureFor,
rivalNames
rivalNames,
isCastilianName,
catalanRivalName
};
Loading
Loading